From 42bdfb8779e285ec422881d0f62a6dd5ecc35169 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 28 Oct 2026 17:15:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(mypage):=20=EC=B5=9C=EC=8B=A0=20=EA=B3=B5?= =?UTF-8?q?=EC=A7=80=20=EB=B0=B0=EC=97=B4=EA=B3=BC=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=EB=A1=A4=EB=A7=81=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 22 ++ SodaLive/Sources/MyPage/MyPageView.swift | 106 +++++-- SodaLive/Sources/MyPage/MyPageViewModel.swift | 15 +- .../Sources/Settings/Notice/NoticeApi.swift | 12 +- .../Settings/Notice/NoticeRepository.swift | 5 +- .../V2/MyPage/Models/MyPageNoticeRoller.swift | 25 ++ work/tests/AuthContentSettingsExitCheck.swift | 9 +- work/tests/MyPageNoticesCheck.swift | 279 ++++++++++++++++++ work/tests/check-my-page-notices.sh | 32 ++ 9 files changed, 462 insertions(+), 43 deletions(-) create mode 100644 SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift create mode 100644 work/tests/MyPageNoticesCheck.swift create mode 100644 work/tests/check-my-page-notices.sh diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index f8f7c232..c8fcdad1 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + AC5280013010280000A5BBA3 /* MyPageNoticeRoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5280033010280000A5BBA3 /* MyPageNoticeRoller.swift */; }; + AC5280023010280000A5BBA3 /* MyPageNoticeRoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5280033010280000A5BBA3 /* MyPageNoticeRoller.swift */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; @@ -2210,6 +2212,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AC5280033010280000A5BBA3 /* MyPageNoticeRoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyPageNoticeRoller.swift; sourceTree = ""; }; 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = ""; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = ""; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; @@ -3305,6 +3308,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + AC5280043010280000A5BBA3 /* MyPage */ = { + isa = PBXGroup; + children = ( + AC5280053010280000A5BBA3 /* Models */, + ); + path = MyPage; + sourceTree = ""; + }; + AC5280053010280000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + AC5280033010280000A5BBA3 /* MyPageNoticeRoller.swift */, + ); + path = Models; + sourceTree = ""; + }; 038B15E425DE0639156ADBA9 /* Repository */ = { isa = PBXGroup; children = ( @@ -6658,6 +6677,7 @@ F18179AD98929A1E018058E0 /* V2 */ = { isa = PBXGroup; children = ( + AC5280043010280000A5BBA3 /* MyPage */, AC5200063012000000A5BBA3 /* Auth */, 976A594BED288A98C0EEA03C /* Component */, D979B1B9166521052893A897 /* Main */, @@ -6987,6 +7007,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AC5280013010280000A5BBA3 /* MyPageNoticeRoller.swift in Sources */, AC5200013012000000A5BBA3 /* AuthVerificationView.swift in Sources */, AC5200043012000000A5BBA3 /* AuthVerificationMock.swift in Sources */, AC5100013012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */, @@ -8126,6 +8147,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AC5280023010280000A5BBA3 /* MyPageNoticeRoller.swift in Sources */, AC5200023012000000A5BBA3 /* AuthVerificationView.swift in Sources */, AC5200053012000000A5BBA3 /* AuthVerificationMock.swift in Sources */, AC5100023012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */, diff --git a/SodaLive/Sources/MyPage/MyPageView.swift b/SodaLive/Sources/MyPage/MyPageView.swift index 59a5fd7f..4ffbef24 100644 --- a/SodaLive/Sources/MyPage/MyPageView.swift +++ b/SodaLive/Sources/MyPage/MyPageView.swift @@ -56,9 +56,9 @@ struct MyPageView: View { isShowButton: !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) - if let notice = viewModel.latestNotice { + if !viewModel.latestNotices.isEmpty { // Update Banner - UpdateBannerView(item: notice) + UpdateBannerView(items: viewModel.latestNotices) } ScrollView(.vertical, showsIndicators: false) { @@ -170,7 +170,7 @@ struct MyPageView: View { viewModel.getMypage() recentContentViewModel.fetchRecentContents() } - viewModel.getLatestNotice() + viewModel.getLatestNotices() } } } @@ -217,34 +217,88 @@ struct MyPageHeaderView: View { // MARK: - Update Banner View struct UpdateBannerView: View { - - let item: NoticeItem - + private static let foregroundColor = Color(hex: "B0BEC5") + private static let rollInterval: TimeInterval = 5 + private static let transitionDuration: TimeInterval = 0.35 + + let items: [NoticeItem] + + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var roller = MyPageNoticeRoller() + @State private var rollTimer: Timer? + @State private var isVisible = false + var body: some View { - HStack { - Text("\(item.title)") - .appFont(size: 16) - .foregroundColor(Color(hex: "B0BEC5")) - - Spacer() - - HStack(spacing: 2) { - Text(I18n.MyPage.Main.detail) - .appFont(size: 16) - .foregroundColor(Color(hex: "B0BEC5")) - - Image(systemName: "chevron.right") - .foregroundColor(Color(hex: "B0BEC5")) - .frame(width: 24, height: 24) + ZStack { + if let item = roller.current { + Button { + AppState.shared.setAppStep(step: .noticeDetail(notice: item)) + } label: { + HStack(spacing: SodaSpacing.s8) { + ZStack { + Text(item.title) + .appFont(.body3) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .id(roller.index) + .transition(reduceMotion ? .identity : .asymmetric( + insertion: .move(edge: .bottom), + removal: .move(edge: .top) + )) + } + .clipped() + + Image(systemName: "chevron.right") + .frame(width: SodaSpacing.s24, height: SodaSpacing.s24) + .accessibilityHidden(true) + } + .foregroundColor(Self.foregroundColor) + .padding(.horizontal, SodaSpacing.s24) + .padding(.vertical, SodaSpacing.s6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } } - .padding(.horizontal, 24) - .padding(.vertical, 6) + .clipped() .background(Color.black) - .contentShape(Rectangle()) - .onTapGesture { - AppState.shared.setAppStep(step: .noticeDetail(notice: item)) + .onAppear { + isVisible = true + roller.update(items) + restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion) } + .onDisappear { + isVisible = false + stopRollTimer() + } + .onChange(of: items) { items in + roller.update(items) + restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion) + } + .onChange(of: scenePhase) { scenePhase in + restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion) + } + .onChange(of: reduceMotion) { reduceMotion in + restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion) + } + } + + private func restartRollTimer(scenePhase: ScenePhase, reduceMotion: Bool) { + stopRollTimer() + guard roller.shouldRoll(isVisible: isVisible, isActive: scenePhase == .active) else { return } + + rollTimer = Timer.scheduledTimer(withTimeInterval: Self.rollInterval, repeats: true) { _ in + withAnimation(reduceMotion ? nil : .easeOut(duration: Self.transitionDuration)) { + roller.advance() + } + } + } + + private func stopRollTimer() { + rollTimer?.invalidate() + rollTimer = nil } } diff --git a/SodaLive/Sources/MyPage/MyPageViewModel.swift b/SodaLive/Sources/MyPage/MyPageViewModel.swift index 0caadcfb..fbc6710d 100644 --- a/SodaLive/Sources/MyPage/MyPageViewModel.swift +++ b/SodaLive/Sources/MyPage/MyPageViewModel.swift @@ -18,7 +18,7 @@ final class MyPageViewModel: ObservableObject { private var pendingAuthRequest: AuthVerifyRequest? private var pendingAuthSuccess: (() -> Void)? - @Published var latestNotice: NoticeItem? = nil + @Published var latestNotices: [NoticeItem] = [] @Published var nickname: String = "" @Published var profileUrl: String = "" @@ -259,8 +259,9 @@ final class MyPageViewModel: ObservableObject { AppState.shared.setAppStep(step: .splash) } - func getLatestNotice() { - noticeRepository.getLatestNotice() + func getLatestNotices() { + noticeRepository.getLatestNotices() + .receive(on: DispatchQueue.main) .sink { result in switch result { case .finished: @@ -270,14 +271,18 @@ final class MyPageViewModel: ObservableObject { } } receiveValue: {[unowned self] response in self.isLoading = false + guard (200..<300).contains(response.statusCode) else { + ERROR_LOG("Latest notices HTTP \(response.statusCode)") + return + } let responseData = response.data do { let jsonDecoder = JSONDecoder() - let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + let decoded = try jsonDecoder.decode(ApiResponse<[NoticeItem]>.self, from: responseData) if let data = decoded.data, decoded.success { - self.latestNotice = data + self.latestNotices = data } } catch { ERROR_LOG(error.localizedDescription) diff --git a/SodaLive/Sources/Settings/Notice/NoticeApi.swift b/SodaLive/Sources/Settings/Notice/NoticeApi.swift index c3b76dd2..1b2b026f 100644 --- a/SodaLive/Sources/Settings/Notice/NoticeApi.swift +++ b/SodaLive/Sources/Settings/Notice/NoticeApi.swift @@ -10,7 +10,7 @@ import Moya enum NoticeApi { case getNotices - case getLatestNotice + case getLatestNotices } extension NoticeApi: TargetType { @@ -23,8 +23,8 @@ extension NoticeApi: TargetType { case .getNotices: return "/notice" - case .getLatestNotice: - return "/notice/latest" + case .getLatestNotices: + return "/api/v2/notices/latest" } } @@ -38,15 +38,17 @@ extension NoticeApi: TargetType { let parameters = ["timezone": TimeZone.current.identifier] as [String: Any] return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) - case .getLatestNotice: + case .getLatestNotices: return .requestPlain } } var headers: [String : String]? { switch self { - default: + case .getNotices: return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + case .getLatestNotices: + return nil } } } diff --git a/SodaLive/Sources/Settings/Notice/NoticeRepository.swift b/SodaLive/Sources/Settings/Notice/NoticeRepository.swift index 8b24e31a..39e01265 100644 --- a/SodaLive/Sources/Settings/Notice/NoticeRepository.swift +++ b/SodaLive/Sources/Settings/Notice/NoticeRepository.swift @@ -18,8 +18,7 @@ final class NoticeRepository { return api.requestPublisher(.getNotices) } - func getLatestNotice() -> AnyPublisher { - return api.requestPublisher(.getLatestNotice) + func getLatestNotices() -> AnyPublisher { + return api.requestPublisher(.getLatestNotices) } } - diff --git a/SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift b/SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift new file mode 100644 index 00000000..bba4fe1f --- /dev/null +++ b/SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift @@ -0,0 +1,25 @@ +import Foundation + +struct MyPageNoticeRoller { + private var items: [NoticeItem] = [] + private(set) var index = 0 + + var current: NoticeItem? { + items.isEmpty ? nil : items[index] + } + + mutating func update(_ items: [NoticeItem]) { + guard self.items != items else { return } + self.items = items + index = 0 + } + + mutating func advance() { + guard items.count > 1 else { return } + index = (index + 1) % items.count + } + + func shouldRoll(isVisible: Bool, isActive: Bool) -> Bool { + items.count > 1 && isVisible && isActive + } +} diff --git a/work/tests/AuthContentSettingsExitCheck.swift b/work/tests/AuthContentSettingsExitCheck.swift index 54acb0cd..b04f6f26 100644 --- a/work/tests/AuthContentSettingsExitCheck.swift +++ b/work/tests/AuthContentSettingsExitCheck.swift @@ -41,7 +41,7 @@ final class UserRepository { } final class NoticeRepository { - func getLatestNotice() -> AnyPublisher { + func getLatestNotices() -> AnyPublisher { let subject = PassthroughSubject() AuthExitIO.noticeSubjects.append(subject) return subject.eraseToAnyPublisher() @@ -337,7 +337,7 @@ struct AuthContentSettingsExitCheck { expect(viewModel.isShowPopup, "Hidden-panel cancel changed unrelated toast") viewModel.presentAuthContentSettings() expect(viewModel.isShowAuthContentSettings, "Hidden-panel cancel discarded receipt") - viewModel.getLatestNotice() + viewModel.getLatestNotices() var cancellationOnMain: Bool? let observation = viewModel.$isShowAuthContentSettings.dropFirst().sink { shown in if !shown { cancellationOnMain = Thread.isMainThread } @@ -350,8 +350,9 @@ struct AuthContentSettingsExitCheck { expect(dispatched.wait(timeout: .now() + 2) == .success, "Background cancellation did not return") wait("background cancellation") { cancellationOnMain != nil } expect(cancellationOnMain == true, "Background cancellation mutated panel off main") - send(AuthExitIO.noticeSubjects[0], json: #"{"success":true,"data":{"title":"Synthetic notice","content":"Synthetic content","date":"synthetic-date"}}"#) - expect(viewModel.latestNotice?.title == "Synthetic notice", "Cancellation removed unrelated shared subscriptions") + send(AuthExitIO.noticeSubjects[0], json: #"{"success":true,"data":[{"title":"Synthetic notice","content":"Synthetic content","date":"synthetic-date"}]}"#) + wait("unrelated notice response") { viewModel.latestNotices.first?.title == "Synthetic notice" } + expect(viewModel.latestNotices.count == 1, "Cancellation removed unrelated shared subscriptions") viewModel.submitPendingAuthVerification() drainMainQueue() expectNoEffects(posts: 0, "background exit") diff --git a/work/tests/MyPageNoticesCheck.swift b/work/tests/MyPageNoticesCheck.swift new file mode 100644 index 00000000..735450b5 --- /dev/null +++ b/work/tests/MyPageNoticesCheck.swift @@ -0,0 +1,279 @@ +import Foundation +import Combine + +#if NOTICE_MOYA_DOUBLE +public enum Method { case get } +public enum URLEncoding { case queryString } +public enum Task { + case requestPlain + case requestParameters(parameters: [String: Any], encoding: URLEncoding) +} +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 NoticeTransport { + public static var request: ((any TargetType) -> AnyPublisher)! +} +public final class MoyaProvider { + public init() {} +} +#elseif NOTICE_COMBINE_MOYA_DOUBLE +import Moya +extension MoyaProvider { + public func requestPublisher(_ target: T) -> AnyPublisher { + NoticeTransport.request(target) + } +} +#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) } +} + +final class UserRepository { + func getMypage() -> AnyPublisher { fatalError("Unexpected member request") } + func getMemberInfo() -> AnyPublisher { fatalError("Unexpected member request") } +} +final class AuthRepository { + func authCertificate(request: AuthVerifyRequest) -> AnyPublisher { + fatalError("Unexpected auth request") + } +} +enum AppStep { case splash } +final class AppState { + static let shared = AppState() + var isRestartApp = false + func setAppStep(step: AppStep) { fatalError("Unexpected navigation") } +} +enum I18n { + enum Common { static let commonError = "commonError" } + enum Main { enum Auth { static let authenticationError = "authenticationError" } } + enum MyPage { enum Auth { static let verificationErrorWithSupport = "verificationErrorWithSupport" } } +} +func DEBUG_LOG(_ message: String) {} +func ERROR_LOG(_ message: String) {} + +@main +struct MyPageNoticesCheck { + static let fixtures = [ + #"{"id":93,"title":"Third published","content":"

Third

Full & unmodified
body

","date":"2026-10-28"}"#, + #"{"id":27,"title":"First alphabetically","content":"

Second HTML

","date":"2026-01-01"}"#, + #"{"id":8,"title":"Middle alphabetically","content":"

Third body

","date":"2025-12-31"}"# + ] + static let titles = ["Third published", "First alphabetically", "Middle alphabetically"] + + static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("FAIL: \(message)\n", stderr) + exit(1) + } + } + + static func envelope(count: Int, nullableFields: Bool = false) -> String { + let optional = nullableFields ? #", "message":null,"errorProperty":null"# : "" + return #"{"success":true,"data":["# + fixtures.prefix(count).joined(separator: ",") + "]" + optional + "}" + } + + 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 checkAPI() { + UserDefaults.set("synthetic-token", forKey: .token) + defer { UserDefaults.standard.removeObject(forKey: UserDefaultsKey.token.rawValue) } + let repository = NoticeRepository() + var paths: [String] = [] + NoticeTransport.request = { target in + paths.append(target.path) + expect(target.method == .get, "Notices request must be GET") + if target.path == "/api/v2/notices/latest" { + expect(target.headers?["Authorization"] == nil, "Public latest request must not send Authorization") + guard case .requestPlain = target.task else { + fatalError("Latest notices must not have body/query") + } + } else { + expect(target.path == "/notice", "Unexpected notices path: \(target.path)") + expect(target.headers?["Authorization"] == "Bearer synthetic-token", "List authorization changed") + guard case let .requestParameters(parameters, encoding) = target.task else { + fatalError("List query removed") + } + expect(parameters.count == 1 && parameters["timezone"] as? String == TimeZone.current.identifier, + "List timezone query changed") + expect(encoding == .queryString, "List query encoding changed") + } + return Empty().eraseToAnyPublisher() + } + _ = repository.getLatestNotices() + _ = repository.getNotices() + expect(paths == ["/api/v2/notices/latest", "/notice"], "Actual repository routes changed") + print("PASS: actual NoticeApi/Repository public v2 GET requestPlain without Authorization; legacy list auth/timezone unchanged") + } + + static func checkDecodingAndRolling() throws { + for count in 0...3 { + for nullableFields in [false, true] { + let response = try JSONDecoder().decode(ApiResponse<[NoticeItem]>.self, + from: Data(envelope(count: count, nullableFields: nullableFields).utf8)) + expect(response.success && response.message == nil && response.errorProperty == nil, + "Omitted/null envelope fields must decode") + let items = response.data! + expect(items.map(\.title) == Array(titles.prefix(count)), "Array server order/count changed") + var roller = MyPageNoticeRoller() + roller.update(items) + expect((roller.current == nil) == (count == 0), "Only empty array must hide banner") + let expected: [String] + switch count { + case 0: expected = [] + case 1: expected = Array(repeating: "Third published", count: 9) + case 2: expected = ["Third published", "First alphabetically", "Third published", "First alphabetically", "Third published", "First alphabetically", "Third published", "First alphabetically", "Third published"] + default: expected = ["Third published", "First alphabetically", "Middle alphabetically", "Third published", "First alphabetically", "Middle alphabetically", "Third published", "First alphabetically", "Middle alphabetically"] + } + for title in expected { + let selected = roller.current! + expect(selected.title == title, "Roll order/wrap or displayed selection changed") + let original = items.first { $0.title == title }! + expect(selected == original, "Selection must preserve entire visible payload") + roller.advance() + } + if count > 0 { + expect(items[0].content == "

Third

Full & unmodified
body

", "HTML transformed") + expect(items[0].date == "2026-10-28", "Seoul date string transformed") + } else { + roller.advance() + expect(roller.current == nil, "Empty roll must stay hidden") + } + for visible in [false, true] { + for active in [false, true] { + expect(roller.shouldRoll(isVisible: visible, isActive: active) == (count > 1 && visible && active), + "Rolling must require multiple items, visible view, active scene") + } + } + } + } + let legacy = try JSONDecoder().decode(GetNoticeResponse.self, from: Data(#"{"totalCount":1,"noticeList":[{"title":"Legacy","content":"

Old list

","date":"2023.08.10"}]}"#.utf8)) + expect(legacy.noticeList.first?.title == "Legacy", "Legacy list must not require id") + let items = try JSONDecoder().decode(ApiResponse<[NoticeItem]>.self, from: Data(envelope(count: 3).utf8)).data! + var roller = MyPageNoticeRoller() + roller.update(items) + roller.advance() + roller.update(items) + expect(roller.current?.title == "First alphabetically", "Identical data must not interrupt rolling") + roller.advance() + roller.update(Array(items.prefix(2))) + expect(roller.current?.title == "Third published", "Shrinking array must reset to first without stale index") + roller.advance() + roller.update([items[2], items[0]]) + expect(roller.current?.title == "Middle alphabetically", "Same-count replacement/order change must reset") + roller.update([items[1]]) + roller.advance() + expect(roller.current == items[1] && !roller.shouldRoll(isVisible: true, isActive: true), "Single replacement must stay fixed") + roller.update([]) + expect(roller.current == nil && !roller.shouldRoll(isVisible: true, isActive: true), "Empty replacement must hide/stop") + roller.update(items) + expect(roller.current == items[0], "Repopulation must start at first") + print("PASS: actual DTO/roller 0/1/2/3, omitted/null envelope, repeated order/wrap/selection, exact HTML/date, legacy no-id, shrink/reset and lifecycle predicate") + } + + static func checkViewModel() { + let viewModel = MyPageViewModel() + var subject = PassthroughSubject() + NoticeTransport.request = { target in + expect(target.path == "/api/v2/notices/latest", "ViewModel must use v2 notices repository") + subject = PassthroughSubject() + return subject.eraseToAnyPublisher() + } + var publications = 0 + let observation = viewModel.$latestNotices.dropFirst().sink { _ in + expect(Thread.isMainThread, "Notices publication must be on main") + publications += 1 + } + for count in [3, 2, 1, 0] { + viewModel.isLoading = true + viewModel.getLatestNotices() + expect(viewModel.isLoading, "Fetch must preserve existing loading start semantics") + let response = Response(statusCode: 200, data: Data(envelope(count: count).utf8)) + let pending = subject + let sent = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + pending.send(response) + pending.send(completion: .finished) + sent.signal() + } + expect(sent.wait(timeout: .now() + 2) == .success, "Background response timed out") + drainMainQueue() + expect(viewModel.latestNotices.map(\.title) == Array(titles.prefix(count)), "VM array/count/empty success not applied") + expect(!viewModel.isLoading, "Response must retain loading completion semantics") + } + expect(publications == 4, "Each successful array must publish once") + expect(viewModel.latestNotices.isEmpty, "Successful empty data must hide banner") + let failures: [(String, String?, Int)] = [ + ("transport", nil, 200), + ("HTTP", envelope(count: 0), 503), + ("HTTP unauthorized", envelope(count: 1), 401), + ("envelope", #"{"success":false,"data":[]}"#, 200), + ("missing data", #"{"success":true}"#, 200), + ("null data", #"{"success":true,"data":null}"#, 200), + ("decode", "invalid-json", 200), + ("invalid DTO", #"{"success":true,"data":[{}]}"#, 200), + ("old singular payload", #"{"success":true,"data":{"title":"Old","content":"body","date":"2026-10-28"}}"#, 200) + ] + let existing = [NoticeItem(title: "Retained", content: "

Retained

", date: "2026-10-27")] + for (name, json, status) in failures { + viewModel.latestNotices = existing + let before = publications + viewModel.isLoading = true + viewModel.errorMessage = "existing-error" + viewModel.getLatestNotices() + if let json = json { + subject.send(Response(statusCode: status, data: Data(json.utf8))) + subject.send(completion: .finished) + } else { + subject.send(completion: .failure(.simulatedTransportFailure)) + } + drainMainQueue() + expect(viewModel.latestNotices == existing && publications == before, "\(name): failure replaced existing notices") + expect(viewModel.errorMessage == "existing-error" && !viewModel.isShowPopup, "\(name): notice error must remain log-only") + expect(viewModel.isLoading == (json == nil), "\(name): existing loading semantics changed") + } + withExtendedLifetime(observation) {} + print("PASS: actual MyPageViewModel background delivery on main, 0/1/2/3 success, empty hides, HTTP/envelope/decode/transport failure preserves data, log-only/loading semantics") + } + + static func main() throws { + checkAPI() + try checkDecodingAndRolling() + checkViewModel() + print("PASS: My Page notices regression checks (actual production API/Repository/VM/DTO/roller, native Combine; no server calls)") + } +} +#endif diff --git a/work/tests/check-my-page-notices.sh b/work/tests/check-my-page-notices.sh new file mode 100644 index 00000000..160bb847 --- /dev/null +++ b/work/tests/check-my-page-notices.sh @@ -0,0 +1,32 @@ +#!/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 NOTICE_MOYA_DOUBLE -emit-module -emit-library -module-name Moya \ + "$ROOT/work/tests/MyPageNoticesCheck.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 NOTICE_COMBINE_MOYA_DOUBLE -emit-module -emit-library -module-name CombineMoya \ + "$ROOT/work/tests/MyPageNoticesCheck.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/Notice/NoticeApi.swift" \ + "$ROOT/SodaLive/Sources/Settings/Notice/NoticeRepository.swift" \ + "$ROOT/SodaLive/Sources/MyPage/MyPageViewModel.swift" \ + "$ROOT/SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift" \ + "$ROOT/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift" \ + "$ROOT/SodaLive/Sources/MyPage/Auth/AuthResponse.swift" \ + "$ROOT/SodaLive/Sources/MyPage/MyPageResponse.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \ + "$ROOT/SodaLive/Sources/Settings/Content/ContentType.swift" \ + "$ROOT/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift" \ + "$ROOT/SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift" \ + "$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \ + "$ROOT/work/tests/MyPageNoticesCheck.swift" \ + -o "$TEMP_DIR/check-my-page-notices" +"$TEMP_DIR/check-my-page-notices"