feat(mypage): 최신 공지 배열과 제목 롤링을 적용한다
This commit is contained in:
@@ -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 = "<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>"; };
|
||||
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
|
||||
@@ -3305,6 +3308,22 @@
|
||||
/* End PBXFrameworksBuildPhase 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 */ = {
|
||||
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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NoticeItem>.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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ final class NoticeRepository {
|
||||
return api.requestPublisher(.getNotices)
|
||||
}
|
||||
|
||||
func getLatestNotice() -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getLatestNotice)
|
||||
func getLatestNotices() -> AnyPublisher<Response, MoyaError> {
|
||||
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 {
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user