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
+22
View File
@@ -7,6 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
@@ -2210,6 +2212,7 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
AC5280033010280000A5BBA3 /* MyPageNoticeRoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyPageNoticeRoller.swift; sourceTree = "<group>"; };
002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = "<group>"; }; 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = "<group>"; };
0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; };
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
@@ -3305,6 +3308,22 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
AC5280043010280000A5BBA3 /* MyPage */ = {
isa = PBXGroup;
children = (
AC5280053010280000A5BBA3 /* Models */,
);
path = MyPage;
sourceTree = "<group>";
};
AC5280053010280000A5BBA3 /* Models */ = {
isa = PBXGroup;
children = (
AC5280033010280000A5BBA3 /* MyPageNoticeRoller.swift */,
);
path = Models;
sourceTree = "<group>";
};
038B15E425DE0639156ADBA9 /* Repository */ = { 038B15E425DE0639156ADBA9 /* Repository */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6658,6 +6677,7 @@
F18179AD98929A1E018058E0 /* V2 */ = { F18179AD98929A1E018058E0 /* V2 */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AC5280043010280000A5BBA3 /* MyPage */,
AC5200063012000000A5BBA3 /* Auth */, AC5200063012000000A5BBA3 /* Auth */,
976A594BED288A98C0EEA03C /* Component */, 976A594BED288A98C0EEA03C /* Component */,
D979B1B9166521052893A897 /* Main */, D979B1B9166521052893A897 /* Main */,
@@ -6987,6 +7007,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AC5280013010280000A5BBA3 /* MyPageNoticeRoller.swift in Sources */,
AC5200013012000000A5BBA3 /* AuthVerificationView.swift in Sources */, AC5200013012000000A5BBA3 /* AuthVerificationView.swift in Sources */,
AC5200043012000000A5BBA3 /* AuthVerificationMock.swift in Sources */, AC5200043012000000A5BBA3 /* AuthVerificationMock.swift in Sources */,
AC5100013012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */, AC5100013012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */,
@@ -8126,6 +8147,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AC5280023010280000A5BBA3 /* MyPageNoticeRoller.swift in Sources */,
AC5200023012000000A5BBA3 /* AuthVerificationView.swift in Sources */, AC5200023012000000A5BBA3 /* AuthVerificationView.swift in Sources */,
AC5200053012000000A5BBA3 /* AuthVerificationMock.swift in Sources */, AC5200053012000000A5BBA3 /* AuthVerificationMock.swift in Sources */,
AC5100023012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */, AC5100023012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */,
+80 -26
View File
@@ -56,9 +56,9 @@ struct MyPageView: View {
isShowButton: !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty isShowButton: !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
) )
if let notice = viewModel.latestNotice { if !viewModel.latestNotices.isEmpty {
// Update Banner // Update Banner
UpdateBannerView(item: notice) UpdateBannerView(items: viewModel.latestNotices)
} }
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
@@ -170,7 +170,7 @@ struct MyPageView: View {
viewModel.getMypage() viewModel.getMypage()
recentContentViewModel.fetchRecentContents() recentContentViewModel.fetchRecentContents()
} }
viewModel.getLatestNotice() viewModel.getLatestNotices()
} }
} }
} }
@@ -217,34 +217,88 @@ struct MyPageHeaderView: View {
// MARK: - Update Banner View // MARK: - Update Banner View
struct UpdateBannerView: View { struct UpdateBannerView: View {
private static let foregroundColor = Color(hex: "B0BEC5")
let item: NoticeItem 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 { var body: some View {
HStack { ZStack {
Text("\(item.title)") if let item = roller.current {
.appFont(size: 16) Button {
.foregroundColor(Color(hex: "B0BEC5")) AppState.shared.setAppStep(step: .noticeDetail(notice: item))
} label: {
Spacer() HStack(spacing: SodaSpacing.s8) {
ZStack {
HStack(spacing: 2) { Text(item.title)
Text(I18n.MyPage.Main.detail) .appFont(.body3)
.appFont(size: 16) .lineLimit(1)
.foregroundColor(Color(hex: "B0BEC5")) .truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right") .id(roller.index)
.foregroundColor(Color(hex: "B0BEC5")) .transition(reduceMotion ? .identity : .asymmetric(
.frame(width: 24, height: 24) 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) .clipped()
.padding(.vertical, 6)
.background(Color.black) .background(Color.black)
.contentShape(Rectangle()) .onAppear {
.onTapGesture { isVisible = true
AppState.shared.setAppStep(step: .noticeDetail(notice: item)) 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
} }
} }
+10 -5
View File
@@ -18,7 +18,7 @@ final class MyPageViewModel: ObservableObject {
private var pendingAuthRequest: AuthVerifyRequest? private var pendingAuthRequest: AuthVerifyRequest?
private var pendingAuthSuccess: (() -> Void)? private var pendingAuthSuccess: (() -> Void)?
@Published var latestNotice: NoticeItem? = nil @Published var latestNotices: [NoticeItem] = []
@Published var nickname: String = "" @Published var nickname: String = ""
@Published var profileUrl: String = "" @Published var profileUrl: String = ""
@@ -259,8 +259,9 @@ final class MyPageViewModel: ObservableObject {
AppState.shared.setAppStep(step: .splash) AppState.shared.setAppStep(step: .splash)
} }
func getLatestNotice() { func getLatestNotices() {
noticeRepository.getLatestNotice() noticeRepository.getLatestNotices()
.receive(on: DispatchQueue.main)
.sink { result in .sink { result in
switch result { switch result {
case .finished: case .finished:
@@ -270,14 +271,18 @@ final class MyPageViewModel: ObservableObject {
} }
} receiveValue: {[unowned self] response in } receiveValue: {[unowned self] response in
self.isLoading = false self.isLoading = false
guard (200..<300).contains(response.statusCode) else {
ERROR_LOG("Latest notices HTTP \(response.statusCode)")
return
}
let responseData = response.data let responseData = response.data
do { do {
let jsonDecoder = JSONDecoder() let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<NoticeItem>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<[NoticeItem]>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self.latestNotice = data self.latestNotices = data
} }
} catch { } catch {
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
@@ -10,7 +10,7 @@ import Moya
enum NoticeApi { enum NoticeApi {
case getNotices case getNotices
case getLatestNotice case getLatestNotices
} }
extension NoticeApi: TargetType { extension NoticeApi: TargetType {
@@ -23,8 +23,8 @@ extension NoticeApi: TargetType {
case .getNotices: case .getNotices:
return "/notice" return "/notice"
case .getLatestNotice: case .getLatestNotices:
return "/notice/latest" return "/api/v2/notices/latest"
} }
} }
@@ -38,15 +38,17 @@ extension NoticeApi: TargetType {
let parameters = ["timezone": TimeZone.current.identifier] as [String: Any] let parameters = ["timezone": TimeZone.current.identifier] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getLatestNotice: case .getLatestNotices:
return .requestPlain return .requestPlain
} }
} }
var headers: [String : String]? { var headers: [String : String]? {
switch self { switch self {
default: case .getNotices:
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
case .getLatestNotices:
return nil
} }
} }
} }
@@ -18,8 +18,7 @@ final class NoticeRepository {
return api.requestPublisher(.getNotices) return api.requestPublisher(.getNotices)
} }
func getLatestNotice() -> AnyPublisher<Response, MoyaError> { func getLatestNotices() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getLatestNotice) return api.requestPublisher(.getLatestNotices)
} }
} }
@@ -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
}
}
@@ -41,7 +41,7 @@ final class UserRepository {
} }
final class NoticeRepository { final class NoticeRepository {
func getLatestNotice() -> AnyPublisher<Response, MoyaError> { func getLatestNotices() -> AnyPublisher<Response, MoyaError> {
let subject = PassthroughSubject<Response, MoyaError>() let subject = PassthroughSubject<Response, MoyaError>()
AuthExitIO.noticeSubjects.append(subject) AuthExitIO.noticeSubjects.append(subject)
return subject.eraseToAnyPublisher() return subject.eraseToAnyPublisher()
@@ -337,7 +337,7 @@ struct AuthContentSettingsExitCheck {
expect(viewModel.isShowPopup, "Hidden-panel cancel changed unrelated toast") expect(viewModel.isShowPopup, "Hidden-panel cancel changed unrelated toast")
viewModel.presentAuthContentSettings() viewModel.presentAuthContentSettings()
expect(viewModel.isShowAuthContentSettings, "Hidden-panel cancel discarded receipt") expect(viewModel.isShowAuthContentSettings, "Hidden-panel cancel discarded receipt")
viewModel.getLatestNotice() viewModel.getLatestNotices()
var cancellationOnMain: Bool? var cancellationOnMain: Bool?
let observation = viewModel.$isShowAuthContentSettings.dropFirst().sink { shown in let observation = viewModel.$isShowAuthContentSettings.dropFirst().sink { shown in
if !shown { cancellationOnMain = Thread.isMainThread } if !shown { cancellationOnMain = Thread.isMainThread }
@@ -350,8 +350,9 @@ struct AuthContentSettingsExitCheck {
expect(dispatched.wait(timeout: .now() + 2) == .success, "Background cancellation did not return") expect(dispatched.wait(timeout: .now() + 2) == .success, "Background cancellation did not return")
wait("background cancellation") { cancellationOnMain != nil } wait("background cancellation") { cancellationOnMain != nil }
expect(cancellationOnMain == true, "Background cancellation mutated panel off main") 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"}}"#) 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") wait("unrelated notice response") { viewModel.latestNotices.first?.title == "Synthetic notice" }
expect(viewModel.latestNotices.count == 1, "Cancellation removed unrelated shared subscriptions")
viewModel.submitPendingAuthVerification() viewModel.submitPendingAuthVerification()
drainMainQueue() drainMainQueue()
expectNoEffects(posts: 0, "background exit") 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"