feat(event): ID 기반 이벤트 상세 API를 연동한다
This commit is contained in:
@@ -658,6 +658,8 @@
|
||||
409142DA2A84CF4900EEBFB1 /* EventListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142D82A84CF4900EEBFB1 /* EventListView.swift */; };
|
||||
409142DC2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DB2A84CF5800EEBFB1 /* EventDetailView.swift */; };
|
||||
409142DD2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DB2A84CF5800EEBFB1 /* EventDetailView.swift */; };
|
||||
ED2911010000000000000001 /* EventDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2910010000000000000001 /* EventDetailViewModel.swift */; };
|
||||
ED2913010000000000000001 /* EventDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2910010000000000000001 /* EventDetailViewModel.swift */; };
|
||||
409142E02A84CF7400EEBFB1 /* TermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DF2A84CF7400EEBFB1 /* TermsView.swift */; };
|
||||
409142E12A84CF7400EEBFB1 /* TermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DF2A84CF7400EEBFB1 /* TermsView.swift */; };
|
||||
409142E32A84CFA300EEBFB1 /* TermsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142E22A84CFA300EEBFB1 /* TermsViewModel.swift */; };
|
||||
@@ -2527,6 +2529,7 @@
|
||||
409142D32A84CE3000EEBFB1 /* NoticeApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeApi.swift; sourceTree = "<group>"; };
|
||||
409142D82A84CF4900EEBFB1 /* EventListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventListView.swift; sourceTree = "<group>"; };
|
||||
409142DB2A84CF5800EEBFB1 /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; };
|
||||
ED2910010000000000000001 /* EventDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
409142DF2A84CF7400EEBFB1 /* TermsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsView.swift; sourceTree = "<group>"; };
|
||||
409142E22A84CFA300EEBFB1 /* TermsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsViewModel.swift; sourceTree = "<group>"; };
|
||||
409142E52A84CFC000EEBFB1 /* Terms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Terms.swift; sourceTree = "<group>"; };
|
||||
@@ -6677,6 +6680,7 @@
|
||||
F18179AD98929A1E018058E0 /* V2 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED2920010000000000000001 /* Settings */,
|
||||
AC5280043010280000A5BBA3 /* MyPage */,
|
||||
AC5200063012000000A5BBA3 /* Auth */,
|
||||
976A594BED288A98C0EEA03C /* Component */,
|
||||
@@ -6687,6 +6691,22 @@
|
||||
path = V2;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED2920010000000000000001 /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED2920010000000000000002 /* Event */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ED2920010000000000000002 /* Event */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED2910010000000000000001 /* EventDetailViewModel.swift */,
|
||||
);
|
||||
path = Event;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F4F9FC33C494BBFD8CE96749 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -7751,6 +7771,7 @@
|
||||
400EA77C2A89EDFE004992CF /* LiveNowAllView.swift in Sources */,
|
||||
409143D52A85A64B00EEBFB1 /* ContentListViewModel.swift in Sources */,
|
||||
409142DC2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */,
|
||||
ED2911010000000000000001 /* EventDetailViewModel.swift in Sources */,
|
||||
40B28AAA2A839D8F008BE463 /* SignUpRequest.swift in Sources */,
|
||||
403085AA2D1188B700CB61DE /* GenerateUrlResponse.swift in Sources */,
|
||||
409142342A848B5300EEBFB1 /* LiveRoomPasswordDialog.swift in Sources */,
|
||||
@@ -8692,6 +8713,7 @@
|
||||
400EA76C2A89C074004992CF /* GetLiveRoomTagResponse.swift in Sources */,
|
||||
400EA77D2A89EDFE004992CF /* LiveNowAllView.swift in Sources */,
|
||||
409142DD2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */,
|
||||
ED2913010000000000000001 /* EventDetailViewModel.swift in Sources */,
|
||||
40B28AAB2A839D8F008BE463 /* SignUpRequest.swift in Sources */,
|
||||
409142352A848B5300EEBFB1 /* LiveRoomPasswordDialog.swift in Sources */,
|
||||
403085AB2D1188B700CB61DE /* GenerateUrlResponse.swift in Sources */,
|
||||
|
||||
@@ -34,7 +34,7 @@ enum AppStep {
|
||||
|
||||
case events
|
||||
|
||||
case eventDetail(event: EventItem)
|
||||
case eventDetail(eventId: Int)
|
||||
|
||||
case terms
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ private struct ContentMainBannerPage: View {
|
||||
.onTapGesture {
|
||||
switch item.type {
|
||||
case .EVENT:
|
||||
AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!))
|
||||
AppState.shared.setAppStep(step: .eventDetail(eventId: item.eventItem!.id))
|
||||
case .CREATOR:
|
||||
AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!))
|
||||
case .SERIES:
|
||||
|
||||
@@ -126,7 +126,7 @@ struct ContentMainBannerImageView: View {
|
||||
.onTapGesture {
|
||||
switch item.type {
|
||||
case .EVENT:
|
||||
AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!))
|
||||
AppState.shared.setAppStep(step: .eventDetail(eventId: item.eventItem!.id))
|
||||
case .CREATOR:
|
||||
AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!))
|
||||
case .SERIES:
|
||||
|
||||
@@ -146,8 +146,8 @@ struct AppStepLayerView: View {
|
||||
case .events:
|
||||
EventListView()
|
||||
|
||||
case .eventDetail(let event):
|
||||
EventDetailView(event: event)
|
||||
case .eventDetail(let eventId):
|
||||
EventDetailView(eventId: eventId)
|
||||
|
||||
case .terms:
|
||||
TermsView(isPrivacyPolicy: false)
|
||||
|
||||
@@ -96,7 +96,7 @@ private struct SectionEventBannerPage: View {
|
||||
.onTapGesture {
|
||||
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let _ = item.detailImageUrl {
|
||||
AppState.shared.setAppStep(step: .eventDetail(event: item))
|
||||
AppState.shared.setAppStep(step: .eventDetail(eventId: item.id))
|
||||
} else if let link = item.link,
|
||||
link.trimmingCharacters(in: .whitespaces).count > 0,
|
||||
let url = URL(string: link),
|
||||
|
||||
@@ -31,7 +31,7 @@ struct EventPopupDialogView: View {
|
||||
.shared
|
||||
.setAppStep(
|
||||
step: .eventDetail(
|
||||
event: eventPopup
|
||||
eventId: eventPopup.id
|
||||
)
|
||||
)
|
||||
AppState.shared.eventPopup = nil
|
||||
|
||||
@@ -11,6 +11,7 @@ import Moya
|
||||
enum EventApi {
|
||||
case getEvents
|
||||
case getEventPopup
|
||||
case getEventDetail(id: Int)
|
||||
}
|
||||
|
||||
extension EventApi: TargetType {
|
||||
@@ -25,24 +26,30 @@ extension EventApi: TargetType {
|
||||
|
||||
case .getEventPopup:
|
||||
return "/event/popup"
|
||||
|
||||
case .getEventDetail(let id):
|
||||
return "/api/v2/events/\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
var method: Moya.Method {
|
||||
switch self {
|
||||
case .getEvents, .getEventPopup:
|
||||
case .getEvents, .getEventPopup, .getEventDetail:
|
||||
return .get
|
||||
}
|
||||
}
|
||||
|
||||
var task: Task {
|
||||
switch self {
|
||||
case .getEvents, .getEventPopup:
|
||||
case .getEvents, .getEventPopup, .getEventDetail:
|
||||
return .requestPlain
|
||||
}
|
||||
}
|
||||
|
||||
var headers: [String : String]? {
|
||||
if case .getEventDetail = self, UserDefaults.string(forKey: .token).isEmpty {
|
||||
return nil
|
||||
}
|
||||
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,38 +9,50 @@ import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct EventDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel: EventDetailViewModel
|
||||
|
||||
let event: EventItem
|
||||
init(eventId: Int) {
|
||||
_viewModel = StateObject(wrappedValue: EventDetailViewModel(eventId: eventId))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
BaseView {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
DetailNavigationBar(title: I18n.Settings.Event.detailTitle)
|
||||
DetailNavigationBar(title: I18n.Settings.Event.detailTitle, backAction: {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
})
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
KFImage(URL(string: event.detailImageUrl!))
|
||||
.cancelOnDisappear(true)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
if case .loaded(let event) = viewModel.state,
|
||||
let detailImageUrl = event.detailImageUrl {
|
||||
KFImage(URL(string: detailImageUrl))
|
||||
.cancelOnDisappear(true)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let link = event.link, link.count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) {
|
||||
Text(I18n.Settings.Event.participateButton)
|
||||
.appFont(size: 18.3, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 16)
|
||||
.frame(width: screenSize().width - 26.7)
|
||||
.background(Color(hex: "3e737c"))
|
||||
.cornerRadius(10)
|
||||
.padding(13.3)
|
||||
.background(Color(hex: "222222"))
|
||||
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
if let url = viewModel.participationURL, UIApplication.shared.canOpenURL(url) {
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
Text(I18n.Settings.Event.participateButton)
|
||||
.appFont(size: 18.3, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 16)
|
||||
.frame(width: screenSize().width - 26.7)
|
||||
.background(Color(hex: "3e737c"))
|
||||
.cornerRadius(10)
|
||||
.padding(13.3)
|
||||
.background(Color(hex: "222222"))
|
||||
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if proxy.safeAreaInsets.bottom > 0 {
|
||||
@@ -52,19 +64,23 @@ struct EventDetailView: View {
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.cancel()
|
||||
}
|
||||
.onReceive(viewModel.$state) { state in
|
||||
if state == .unavailable, viewModel.takeUnavailableDismissal() {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EventDetailView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
EventDetailView(
|
||||
event: EventItem(
|
||||
id: 1,
|
||||
thumbnailImageUrl: "",
|
||||
detailImageUrl: "",
|
||||
popupImageUrl: "",
|
||||
link: "http://m.naver.com"
|
||||
)
|
||||
)
|
||||
EventDetailView(eventId: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ struct EventListView: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if let _ = event.detailImageUrl {
|
||||
AppState.shared.setAppStep(step: .eventDetail(event: event))
|
||||
AppState.shared.setAppStep(step: .eventDetail(eventId: event.id))
|
||||
} else if let link = event.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
@@ -20,5 +20,8 @@ final class EventRepository {
|
||||
func getEventPopup() -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getEventPopup)
|
||||
}
|
||||
}
|
||||
|
||||
func getEventDetail(id: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getEventDetail(id: id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,7 +637,7 @@ struct MainView: View {
|
||||
private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) {
|
||||
if let eventItem = item.eventItem {
|
||||
performRecommendationDetailAction {
|
||||
appState.setAppStep(step: .eventDetail(event: eventItem))
|
||||
appState.setAppStep(step: .eventDetail(eventId: eventItem.id))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -660,7 +660,7 @@ struct MainView: View {
|
||||
private func handleContentAudioBannerTap(_ item: AudioBannerResponse) {
|
||||
if let eventItem = item.eventItem {
|
||||
performRecommendationDetailAction {
|
||||
appState.setAppStep(step: .eventDetail(event: eventItem))
|
||||
appState.setAppStep(step: .eventDetail(eventId: eventItem.id))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
final class EventDetailViewModel: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(EventItem)
|
||||
case unavailable
|
||||
case error
|
||||
}
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
|
||||
private let eventId: Int
|
||||
private let repository = EventRepository()
|
||||
private var subscription: AnyCancellable?
|
||||
private var activeRequestID: UUID?
|
||||
private var isUnavailableDismissalPending = false
|
||||
|
||||
init(eventId: Int) {
|
||||
self.eventId = eventId
|
||||
}
|
||||
|
||||
var participationURL: URL? {
|
||||
guard case .loaded(let event) = state,
|
||||
let link = event.link,
|
||||
let url = URL(string: link),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
["http", "https"].contains(scheme),
|
||||
let host = url.host, !host.isEmpty else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
func load() {
|
||||
guard state == .idle else { return }
|
||||
guard eventId > 0 else {
|
||||
showUnavailable()
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
state = .loading
|
||||
let requestID = UUID()
|
||||
activeRequestID = requestID
|
||||
|
||||
subscription = repository.getEventDetail(id: eventId)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.activeRequestID == requestID else { return }
|
||||
self.activeRequestID = nil
|
||||
self.showError()
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self, self.activeRequestID == requestID else { return }
|
||||
self.activeRequestID = nil
|
||||
self.isLoading = false
|
||||
|
||||
guard response.statusCode == 200 else {
|
||||
self.showError()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let envelope = try decoder.decode(ApiResponseWithoutData.self, from: response.data)
|
||||
guard envelope.success else {
|
||||
self.showUnavailable()
|
||||
return
|
||||
}
|
||||
let decoded = try decoder.decode(ApiResponse<EventItem>.self, from: response.data)
|
||||
guard let event = decoded.data, event.id > 0, event.id == self.eventId else {
|
||||
self.showError()
|
||||
return
|
||||
}
|
||||
self.state = .loaded(event)
|
||||
} catch {
|
||||
self.showError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
activeRequestID = nil
|
||||
isUnavailableDismissalPending = false
|
||||
subscription?.cancel()
|
||||
subscription = nil
|
||||
isLoading = false
|
||||
if state == .loading {
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
func takeUnavailableDismissal() -> Bool {
|
||||
guard isUnavailableDismissalPending else { return false }
|
||||
isUnavailableDismissalPending = false
|
||||
return true
|
||||
}
|
||||
|
||||
private func showUnavailable() {
|
||||
isLoading = false
|
||||
isUnavailableDismissalPending = true
|
||||
state = .unavailable
|
||||
}
|
||||
|
||||
private func showError() {
|
||||
isLoading = false
|
||||
errorMessage = I18n.Common.commonError
|
||||
isShowPopup = true
|
||||
state = .error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if EVENT_MOYA_DOUBLE
|
||||
public enum Method { case get }
|
||||
public enum Task { case requestPlain }
|
||||
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 EventTransport {
|
||||
public static var request: ((any TargetType) -> AnyPublisher<Response, MoyaError>)!
|
||||
}
|
||||
public final class MoyaProvider<T: TargetType> {
|
||||
public init() {}
|
||||
}
|
||||
#elseif EVENT_COMBINE_MOYA_DOUBLE
|
||||
import Moya
|
||||
extension MoyaProvider {
|
||||
public func requestPublisher(_ target: T) -> AnyPublisher<Response, MoyaError> {
|
||||
EventTransport.request(target)
|
||||
}
|
||||
}
|
||||
#elseif EVENT_ROUTE_CHECK
|
||||
@main
|
||||
struct EventRouteCheck {
|
||||
static func main() throws {
|
||||
let root = CommandLine.arguments[1] + "/SodaLive/Sources/"
|
||||
let step = try String(contentsOfFile: root + "App/AppStep.swift", encoding: .utf8)
|
||||
guard step.contains("case eventDetail(eventId: Int)") else {
|
||||
fputs("FAIL: event detail route still retains payload instead of Int ID\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let callers = ["Settings/Event/EventListView.swift", "Main/EventPopupDialogView.swift",
|
||||
"Live/EventBanner/SectionEventBannerView.swift", "Content/Main/Banner/ContentMainBannerView.swift",
|
||||
"Content/Main/V2/ContentMainBannerViewV2.swift", "V2/Main/MainView.swift"]
|
||||
var count = 0
|
||||
for file in callers {
|
||||
let source = try String(contentsOfFile: root + file, encoding: .utf8)
|
||||
let pattern = #"\.eventDetail\(\s*eventId:\s*[\w.!]+\.id\s*\)"#
|
||||
let matches = source.matches(of: try Regex(pattern)).count
|
||||
guard matches == (file == "V2/Main/MainView.swift" ? 2 : 1),
|
||||
!source.contains(".eventDetail(event:") else {
|
||||
fatalError("ID-only caller missing: \(file)")
|
||||
}
|
||||
count += matches
|
||||
}
|
||||
let destination = try String(contentsOfFile: root + "ContentView.swift", encoding: .utf8)
|
||||
guard destination.contains("EventDetailView(eventId: eventId)") else { fatalError("ID destination missing") }
|
||||
print("PASS: supplementary ID-only route/destination and \(count) existing navigation call sites")
|
||||
}
|
||||
}
|
||||
#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 = "commonError" } }
|
||||
|
||||
@main
|
||||
struct EventDetailCheck {
|
||||
static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() {
|
||||
fputs("FAIL: \(message)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func drainMainQueue() {
|
||||
var drained = false
|
||||
DispatchQueue.main.async { drained = true }
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while !drained && Date() < deadline {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.001))
|
||||
}
|
||||
expect(drained, "Main queue did not drain")
|
||||
}
|
||||
|
||||
static func json(id: Int = 42, fields: String = #", "detailImageUrl":null,"popupImageUrl":null,"link":null"#) -> String {
|
||||
#"{"success":true,"data":{"id":\#(id),"thumbnailImageUrl":"https://synthetic.invalid/thumb"\#(fields)},"message":null,"errorProperty":null}"#
|
||||
}
|
||||
|
||||
static func response(_ json: String, status: Int = 200) -> Response {
|
||||
Response(statusCode: status, data: Data(json.utf8))
|
||||
}
|
||||
|
||||
static func installTransport(_ subject: PassthroughSubject<Response, MoyaError>) {
|
||||
EventTransport.request = { target in
|
||||
expect(target.path == "/api/v2/events/42", "VM must use actual detail repository")
|
||||
return subject.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
static func checkAPI() {
|
||||
expect(Int.max == 9_223_372_036_854_775_807, "Supported Int must be signed 64-bit")
|
||||
let repository = EventRepository()
|
||||
for token in ["", "synthetic-token"] {
|
||||
UserDefaults.set(token, forKey: .token)
|
||||
var paths: [String] = []
|
||||
EventTransport.request = { target in
|
||||
paths.append(target.path)
|
||||
expect(target.baseURL.absoluteString == BASE_URL && target.method == .get, "Base URL or GET changed")
|
||||
guard case .requestPlain = target.task else { fatalError("No body/query permitted") }
|
||||
let legacy = target.path == "/event" || target.path == "/event/popup"
|
||||
expect(target.headers?["Authorization"] == (legacy || !token.isEmpty ? "Bearer \(token)" : nil),
|
||||
"Detail optional auth or legacy headers changed")
|
||||
return Empty<Response, MoyaError>().eraseToAnyPublisher()
|
||||
}
|
||||
_ = repository.getEventDetail(id: 42)
|
||||
_ = repository.getEventDetail(id: 4_294_967_296)
|
||||
_ = repository.getEventDetail(id: Int.max)
|
||||
_ = repository.getEvents()
|
||||
_ = repository.getEventPopup()
|
||||
expect(paths == ["/api/v2/events/42", "/api/v2/events/4294967296", "/api/v2/events/9223372036854775807", "/event", "/event/popup"], "Repository paths/64-bit ID changed")
|
||||
}
|
||||
print("PASS: actual API/repository exact GET/requestPlain, anonymous/bearer, legacy routes/headers, >32-bit and Int.max")
|
||||
}
|
||||
|
||||
static func checkSuccess() {
|
||||
for id in [42, 4_294_967_296, Int.max] {
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
var requests = 0
|
||||
EventTransport.request = { target in
|
||||
requests += 1
|
||||
expect(target.path == "/api/v2/events/\(id)", "ID truncated")
|
||||
return subject.eraseToAnyPublisher()
|
||||
}
|
||||
let model = EventDetailViewModel(eventId: id)
|
||||
var states: [EventDetailViewModel.State] = []
|
||||
let observation = model.$state.dropFirst().sink {
|
||||
expect(Thread.isMainThread, "State must publish on main")
|
||||
states.append($0)
|
||||
}
|
||||
model.load()
|
||||
model.load()
|
||||
expect(model.isLoading && model.state == .loading && requests == 1, "Loading must deduplicate")
|
||||
let sent = DispatchSemaphore(value: 0)
|
||||
DispatchQueue.global().async {
|
||||
subject.send(response(json(id: id)))
|
||||
subject.send(completion: .finished)
|
||||
sent.signal()
|
||||
}
|
||||
expect(sent.wait(timeout: .now() + 2) == .success, "Background response timed out")
|
||||
drainMainQueue()
|
||||
guard case .loaded(let item) = model.state else { fatalError("Object must load") }
|
||||
expect(item.id == id && item.thumbnailImageUrl == "https://synthetic.invalid/thumb", "Production DTO data lost")
|
||||
expect(item.detailImageUrl == nil && item.popupImageUrl == nil && item.link == nil, "Null optionals changed")
|
||||
expect(!model.isLoading && !model.isShowPopup && states.count == 2, "Loading or success publication wrong")
|
||||
model.cancel()
|
||||
model.load()
|
||||
expect(requests == 1, "Same loaded screen must not refetch on reappearance")
|
||||
withExtendedLifetime(observation) {}
|
||||
}
|
||||
for fields in ["", #", "detailImageUrl":"https://synthetic.invalid/detail""#,
|
||||
#", "link":"https://synthetic.invalid/join""#,
|
||||
#", "detailImageUrl":"https://synthetic.invalid/detail","link":"http://synthetic.invalid/join""#] {
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
installTransport(subject)
|
||||
let model = EventDetailViewModel(eventId: 42)
|
||||
model.load()
|
||||
subject.send(response(json(fields: fields)))
|
||||
subject.send(completion: .finished)
|
||||
drainMainQueue()
|
||||
guard case .loaded(let item) = model.state else { fatalError("Image-only/link-only/both/neither must load") }
|
||||
expect((model.participationURL != nil) == (item.link != nil), "Optional link must be independent of image")
|
||||
}
|
||||
print("PASS: actual DTO/VM single object, optional omitted/null, image-only/link-only/both/neither, 64-bit IDs, main delivery, no refetch")
|
||||
}
|
||||
|
||||
static func checkUnavailableAndErrors() {
|
||||
for id in [0, -1, Int.min] {
|
||||
EventTransport.request = { _ in fatalError("Nonpositive ID must not request") }
|
||||
let model = EventDetailViewModel(eventId: id)
|
||||
model.load()
|
||||
model.load()
|
||||
expect(model.state == .unavailable && !model.isLoading, "Invalid ID unavailable/loading")
|
||||
expect(model.takeUnavailableDismissal() && !model.takeUnavailableDismissal(), "Invalid ID must dismiss once")
|
||||
}
|
||||
for data in ["null", "[]", "{}", #"{"id":"bad"}"#] {
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
installTransport(subject)
|
||||
let model = EventDetailViewModel(eventId: 42)
|
||||
var pops = 0
|
||||
let observation = model.$state.sink { state in
|
||||
if state == .unavailable && model.takeUnavailableDismissal() { pops += 1 }
|
||||
}
|
||||
model.load()
|
||||
let value = response(#"{"success":false,"data":\#(data),"message":"다른 언어의 메시지"}"#)
|
||||
subject.send(value)
|
||||
subject.send(value)
|
||||
subject.send(completion: .finished)
|
||||
drainMainQueue()
|
||||
model.load()
|
||||
expect(model.state == .unavailable && !model.isLoading && pops == 1, "False must ignore data shape/message and dismiss once")
|
||||
expect(!model.isShowPopup, "Unavailable must not become response-error toast")
|
||||
withExtendedLifetime(observation) {}
|
||||
}
|
||||
let failures: [(String, Int)] = [
|
||||
(#"{"success":true}"#, 200), (#"{"success":true,"data":null}"#, 200),
|
||||
(#"{"success":true,"data":[]}"#, 200), (#"{"success":true,"data":{}}"#, 200),
|
||||
(#"{"success":true,"data":{"id":42}}"#, 200),
|
||||
(#"{"success":true,"data":{"id":42,"thumbnailImageUrl":null}}"#, 200),
|
||||
(#"{"success":true,"data":{"id":42,"thumbnailImageUrl":7}}"#, 200),
|
||||
(json(id: 0), 200), (json(id: -1), 200), (json(id: 43), 200),
|
||||
(json(fields: #", "link":[]"#), 200), ("broken-json", 200),
|
||||
("<html>unavailable</html>", 503), (json(), 503),
|
||||
(#"{"success":false,"data":null}"#, 503),
|
||||
(#"{"success":false,"data":null}"#, 401), (json(), 401), (json(), 201)
|
||||
]
|
||||
for (payload, status) in failures {
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
installTransport(subject)
|
||||
let model = EventDetailViewModel(eventId: 42)
|
||||
model.load()
|
||||
subject.send(response(payload, status: status))
|
||||
subject.send(completion: .finished)
|
||||
drainMainQueue()
|
||||
expect(model.state == .error && !model.isLoading && model.isShowPopup, "HTTP \(status)/malformed success must be error")
|
||||
expect(model.errorMessage == I18n.Common.commonError && !model.takeUnavailableDismissal(), "Error must not pop/classify unavailable")
|
||||
}
|
||||
for transportFailure in [true, false] {
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
installTransport(subject)
|
||||
let model = EventDetailViewModel(eventId: 42)
|
||||
model.load()
|
||||
subject.send(completion: transportFailure ? .failure(.simulatedTransportFailure) : .finished)
|
||||
drainMainQueue()
|
||||
expect(model.state == .error && !model.isLoading && model.isShowPopup && !model.takeUnavailableDismissal(), "Transport/empty completion must terminate loading without pop")
|
||||
}
|
||||
print("PASS: nonpositive no request; false ignores malformed data/localized message; malformed success/HTML503/abnormal HTTP/401/transport stay error, loading ends, one dismissal")
|
||||
}
|
||||
|
||||
static func checkLinksAndCancellation() {
|
||||
for link in ["https://synthetic.invalid/join", "http://synthetic.invalid/join", "HTTPS://synthetic.invalid/join",
|
||||
"", " ", "/join", "//synthetic.invalid/join", "https:///", "https://", "not a URL",
|
||||
"javascript:alert(1)", "data:text/plain,test", "file:///tmp/test", "sodalive://join", "mailto:a@example.invalid"] {
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
installTransport(subject)
|
||||
let model = EventDetailViewModel(eventId: 42)
|
||||
model.load()
|
||||
let encoded = String(data: try! JSONEncoder().encode(link), encoding: .utf8)!
|
||||
subject.send(response(json(fields: ",\"link\":\(encoded)")))
|
||||
drainMainQueue()
|
||||
guard case .loaded = model.state else { fatalError("Invalid link must not invalidate detail") }
|
||||
let allowed = ["https://synthetic.invalid/join", "http://synthetic.invalid/join", "HTTPS://synthetic.invalid/join"].contains(link)
|
||||
expect((model.participationURL != nil) == allowed, "Unsafe URL accepted or HTTP(S) rejected: \(link)")
|
||||
}
|
||||
let oldSubject = PassthroughSubject<Response, MoyaError>()
|
||||
let newSubject = PassthroughSubject<Response, MoyaError>()
|
||||
var requests = 0
|
||||
var cancels = 0
|
||||
EventTransport.request = { _ in
|
||||
requests += 1
|
||||
return (requests == 1 ? oldSubject : newSubject)
|
||||
.handleEvents(receiveCancel: { cancels += 1 }).eraseToAnyPublisher()
|
||||
}
|
||||
let model = EventDetailViewModel(eventId: 42)
|
||||
model.load()
|
||||
oldSubject.send(response(#"{"success":false,"data":null}"#))
|
||||
model.cancel()
|
||||
expect(!model.isLoading && model.state == .idle && cancels == 1, "Disappearance must cancel/reset pending request")
|
||||
oldSubject.send(response(json()))
|
||||
drainMainQueue()
|
||||
expect(model.state == .idle && !model.takeUnavailableDismissal(), "Queued/late response after disappearance must not change/pop")
|
||||
model.load()
|
||||
oldSubject.send(response(#"{"success":false,"data":null}"#))
|
||||
newSubject.send(response(json()))
|
||||
newSubject.send(response(#"{"success":false,"data":null}"#))
|
||||
drainMainQueue()
|
||||
guard case .loaded = model.state else { fatalError("Only new request's first response may win") }
|
||||
expect(requests == 2 && !model.takeUnavailableDismissal(), "Duplicate/old response must not pop another screen")
|
||||
let unavailableSubject = PassthroughSubject<Response, MoyaError>()
|
||||
installTransport(unavailableSubject)
|
||||
let unavailable = EventDetailViewModel(eventId: 42)
|
||||
unavailable.load()
|
||||
unavailableSubject.send(response(#"{"success":false,"data":null}"#))
|
||||
drainMainQueue()
|
||||
unavailable.cancel()
|
||||
expect(!unavailable.takeUnavailableDismissal(), "Disappeared unavailable view must not later dismiss another screen")
|
||||
print("PASS: actual participation URL HTTP(S)+host boundary; cancel/queued/late/stale/duplicate responses, reload after cancellation, disappearance suppresses dismissal")
|
||||
}
|
||||
|
||||
static func main() {
|
||||
checkAPI()
|
||||
checkSuccess()
|
||||
checkUnavailableAndErrors()
|
||||
checkLinksAndCancellation()
|
||||
print("PASS: Event detail regression checks (actual production API/repository/VM/DTO, native Combine; no server calls)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \
|
||||
-D EVENT_MOYA_DOUBLE -emit-module -emit-library -module-name Moya \
|
||||
"$ROOT/work/tests/EventDetailCheck.swift" -o "$TEMP_DIR/libMoya.dylib"
|
||||
swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \
|
||||
-I "$TEMP_DIR" -L "$TEMP_DIR" -lMoya \
|
||||
-D EVENT_COMBINE_MOYA_DOUBLE -emit-module -emit-library -module-name CombineMoya \
|
||||
"$ROOT/work/tests/EventDetailCheck.swift" -o "$TEMP_DIR/libCombineMoya.dylib"
|
||||
swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \
|
||||
-I "$TEMP_DIR" -L "$TEMP_DIR" -lMoya -lCombineMoya \
|
||||
-Xlinker -rpath -Xlinker "$TEMP_DIR" \
|
||||
"$ROOT/SodaLive/Sources/Settings/Event/EventApi.swift" \
|
||||
"$ROOT/SodaLive/Sources/Settings/Event/EventRepository.swift" \
|
||||
"$ROOT/SodaLive/Sources/Settings/Event/Event.swift" \
|
||||
"$ROOT/SodaLive/Sources/V2/Settings/Event/EventDetailViewModel.swift" \
|
||||
"$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \
|
||||
"$ROOT/SodaLive/Sources/Common/ApiResponseWithoutData.swift" \
|
||||
"$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \
|
||||
"$ROOT/work/tests/EventDetailCheck.swift" \
|
||||
-o "$TEMP_DIR/check-event-detail"
|
||||
"$TEMP_DIR/check-event-detail"
|
||||
swiftc -swift-version 5 -parse-as-library -module-cache-path "$TEMP_DIR/module-cache" \
|
||||
-D EVENT_ROUTE_CHECK "$ROOT/work/tests/EventDetailCheck.swift" -o "$TEMP_DIR/check-event-routes"
|
||||
"$TEMP_DIR/check-event-routes" "$ROOT"
|
||||
Reference in New Issue
Block a user