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