feat(mypage): 최신 공지 배열과 제목 롤링을 적용한다
This commit is contained in:
@@ -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<Response, MoyaError>)!
|
||||
}
|
||||
public final class MoyaProvider<T: TargetType> {
|
||||
public init() {}
|
||||
}
|
||||
#elseif NOTICE_COMBINE_MOYA_DOUBLE
|
||||
import Moya
|
||||
extension MoyaProvider {
|
||||
public func requestPublisher(_ target: T) -> AnyPublisher<Response, MoyaError> {
|
||||
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<Response, MoyaError> { fatalError("Unexpected member request") }
|
||||
func getMemberInfo() -> AnyPublisher<Response, MoyaError> { fatalError("Unexpected member request") }
|
||||
}
|
||||
final class AuthRepository {
|
||||
func authCertificate(request: AuthVerifyRequest) -> AnyPublisher<Response, MoyaError> {
|
||||
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":"<h1>Third</h1><p>Full & unmodified<br>body</p>","date":"2026-10-28"}"#,
|
||||
#"{"id":27,"title":"First alphabetically","content":"<p>Second <strong>HTML</strong></p>","date":"2026-01-01"}"#,
|
||||
#"{"id":8,"title":"Middle alphabetically","content":"<p>Third body</p>","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<Response, MoyaError>().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 == "<h1>Third</h1><p>Full & unmodified<br>body</p>", "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":"<p>Old list</p>","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<Response, MoyaError>()
|
||||
NoticeTransport.request = { target in
|
||||
expect(target.path == "/api/v2/notices/latest", "ViewModel must use v2 notices repository")
|
||||
subject = PassthroughSubject<Response, MoyaError>()
|
||||
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: "<p>Retained</p>", 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
|
||||
Reference in New Issue
Block a user