feat(mypage): 최신 공지 배열과 제목 롤링을 적용한다

This commit is contained in:
Yu Sung
2026-10-28 17:15:38 +09:00
parent 8c08861b34
commit 42bdfb8779
9 changed files with 462 additions and 43 deletions
@@ -41,7 +41,7 @@ final class UserRepository {
}
final class NoticeRepository {
func getLatestNotice() -> AnyPublisher<Response, MoyaError> {
func getLatestNotices() -> AnyPublisher<Response, MoyaError> {
let subject = PassthroughSubject<Response, MoyaError>()
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")
+279
View File
@@ -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 &amp; 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 &amp; 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
+32
View File
@@ -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"