diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift index 33f0edbc..35584024 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -61,8 +61,8 @@ public struct ClassCoordinator { case let .members(members)?: return members.filter == nil && members.editName == nil && members.customAlert == nil && !members.isSaving - case .ticket?: - return true + case let .ticket(ticket)?: + return ticket.customAlert == nil default: return false } @@ -190,7 +190,8 @@ public struct ClassCoordinator { effect, cancelRemovedReportLoads(state: &state), cancelRemovedChatObservers(state: &state), - cancelRemovedClassEffects(state: &state) + cancelRemovedClassEffects(state: &state), + cancelRemovedMyOpinionsLoads(state: state) ) } } @@ -450,7 +451,12 @@ private extension ClassCoordinator { .cancel(id: ClassAIVoteFeature.CancelID.submit(id)), ] } - + removedTickets.map { .cancel(id: ClassTicketFeature.CancelID.check($0)) } + + removedTickets.flatMap { id in + [ + .cancel(id: ClassTicketFeature.CancelID.check(id)), + .cancel(id: ClassTicketFeature.CancelID.existingTicket(id)), + ] + } + removedBattleFlows.flatMap { id in [ .cancel(id: ClassBattleFlowFeature.CancelID.load(id)), @@ -462,6 +468,7 @@ private extension ClassCoordinator { .cancel(id: ClassBattleFlowFeature.CancelID.voteStats(id)), .cancel(id: ClassBattleFlowFeature.CancelID.perspectiveLike(id)), .cancel(id: ClassBattleFlowFeature.CancelID.myPerspective(id)), + .cancel(id: ClassBattleFlowFeature.CancelID.perspectiveMutation(id)), ] } guard !removedBattleFlows.isEmpty else { return .merge(effects) } @@ -481,6 +488,20 @@ private extension ClassCoordinator { return .merge(removedIDs.map { .cancel(id: ChatRoomFeature.CancelID.audioObserver($0)) }) } + /// 내 의견 화면은 항상 최상단 하나뿐이고 CancelID가 전역이므로, 라우트에 없으면 남은 조회를 모두 취소한다. + func cancelRemovedMyOpinionsLoads(state: State) -> Effect { + let hasMyOpinions = state.routes.contains { route in + guard case .myOpinions = route.screen else { return false } + return true + } + guard !hasMyOpinions else { return .none } + return .merge( + .cancel(id: ClassMyOpinionsFeature.CancelID.fetch), + .cancel(id: ClassMyOpinionsFeature.CancelID.replies), + .cancel(id: ClassMyOpinionsFeature.CancelID.moreReplies) + ) + } + func cancelRemovedReportLoads(state: inout State) -> Effect { let reportIDs = Set(state.routes.compactMap { route -> UUID? in guard case let .report(report) = route.screen else { return nil } @@ -500,7 +521,10 @@ private extension ClassCoordinator { cancelledReports.flatMap { id in [ .cancel(id: ClassReportFeature.CancelID.load(id)), + .cancel(id: ClassReportFeature.CancelID.voteHistory(id)), .cancel(id: ClassReportFeature.CancelID.generate(id)), + .cancel(id: ClassReportFeature.CancelID.insight(id)), + .cancel(id: ClassReportFeature.CancelID.myActivity(id)), ] } + cancelledDashboards.flatMap { id in @@ -508,6 +532,7 @@ private extension ClassCoordinator { .cancel(id: ClassOwnerDashboardFeature.CancelID.load(id)), .cancel(id: ClassOwnerDashboardFeature.CancelID.generate(id)), .cancel(id: ClassOwnerDashboardFeature.CancelID.generatePersonal(id)), + .cancel(id: ClassOwnerDashboardFeature.CancelID.myActivity(id)), ] } ) @@ -587,6 +612,30 @@ private extension ClassCoordinator { state.routes.push(.ticket(registration)) return .none + case let .routeAction(routeID, action: .ticket(.delegate(.existingTicketFound(ticket)))): + // 내가 등록한 사용 중 이용권을 다시 입력함: 바로 아래가 개요면 그리로 돌아가고, 아니면 이 화면을 개요로 바꾼다. + guard routeID == state.routes.count - 1, + case var .ticket(current)? = state.routes.last?.screen + else { return .none } + if routeID >= 1, + case var .ticket(previous) = state.routes[routeID - 1].screen, + previous.phase == .overview + { + state.routes.goBack() + previous.ticket = ticket + previous.registrationStartDate = ticket.activatedAt + previous.expirationDate = ticket.expiresAt + state.routes[screenAt: routeID - 1] = .ticket(previous) + return .none + } + current.preview = nil + current.ticket = ticket + current.registrationStartDate = ticket.activatedAt + current.expirationDate = ticket.expiresAt + current.phase = .overview + state.routes[screenAt: routeID] = .ticket(current) + return .none + case let .routeAction(routeID, action: .ticket(.delegate(.dismiss))): guard routeID == state.routes.count - 1, case .ticket = state.routes.last?.screen @@ -669,7 +718,11 @@ private extension ClassCoordinator { state.routes.push(.myClasses(.init())) return .none - case .routeAction(_, action: .intro(.delegate(.create))): + case let .routeAction(routeID, action: .intro(.delegate(.create))): + // 이용권 확인 응답이 늦게 와도 소개 화면이 맨 위일 때만 만들기 흐름을 연다. + guard routeID == 0, state.routes.count == 1, + case .intro = state.routes[0].screen + else { return .none } invalidateCompletingJoin(state: &state) state.routes.push(.startMethod(.init())) return .none diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift index c2c59f53..e1630292 100644 --- a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift +++ b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift @@ -73,7 +73,7 @@ public struct MainTabCoordinator { case .classroom: return !classState.showsTabBar case .myPage: - if case .ticket? = myPageState.routes.last?.screen { return false } + if case let .ticket(ticket)? = myPageState.routes.last?.screen { return ticket.customAlert != nil } return myPageState.routes.count > 1 case .none: return false @@ -186,6 +186,20 @@ public struct MainTabCoordinator { state.selectedTab = Tab.myPage.rawValue return .none + case .classroom(.router(.routeAction(0, action: .intro(.delegate(.registerTicket))))): + // 만료 안내의 등록하기: Android와 같이 마이 탭의 코드 입력 화면으로 바로 간다. + state.classState.routes.goBackToRoot() + state.myPageState.routes.goBackToRoot() + var registration = ClassTicketFeature.State() + registration.shouldCheckExistingTicket = false + state.myPageState.routes.push(.ticket(registration)) + if state.selectedTab != Tab.myPage.rawValue { + state.previousTab = state.selectedTab + trackTabSelection(Tab.myPage.rawValue) + } + state.selectedTab = Tab.myPage.rawValue + return .none + case .myPage(.router(.routeAction(_, action: .settings(.delegate(.sessionEnded))))): return .send(.delegate(.sessionEnded)) diff --git a/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift index 9a89082d..7568bbdc 100644 --- a/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift +++ b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift @@ -75,7 +75,12 @@ private extension ProfileCoordinator { }) let removedIDs = state.ticketInstanceIDs.subtracting(currentIDs) state.ticketInstanceIDs = currentIDs - return .merge(removedIDs.map { .cancel(id: ClassTicketFeature.CancelID.check($0)) }) + return .merge(removedIDs.flatMap { id in + [ + .cancel(id: ClassTicketFeature.CancelID.check(id)), + .cancel(id: ClassTicketFeature.CancelID.existingTicket(id)), + ] + }) } func routerAction( @@ -215,6 +220,30 @@ private extension ProfileCoordinator { state.routes.push(.ticket(overview)) return .none + case let .routeAction(routeID, action: .ticket(.delegate(.existingTicketFound(ticket)))): + // 내가 등록한 사용 중 이용권을 다시 입력함: 바로 아래가 개요면 그리로 돌아가고, 아니면 이 화면을 개요로 바꾼다. + guard routeID == state.routes.count - 1, + case var .ticket(current)? = state.routes.last?.screen + else { return .none } + if routeID >= 1, + case var .ticket(previous) = state.routes[routeID - 1].screen, + previous.phase == .overview + { + state.routes.goBack() + previous.ticket = ticket + previous.registrationStartDate = ticket.activatedAt + previous.expirationDate = ticket.expiresAt + state.routes[screenAt: routeID - 1] = .ticket(previous) + return .none + } + current.preview = nil + current.ticket = ticket + current.registrationStartDate = ticket.activatedAt + current.expirationDate = ticket.expiresAt + current.phase = .overview + state.routes[screenAt: routeID] = .ticket(current) + return .none + case let .routeAction(routeID, action: .ticket(.delegate(.dismiss))): guard routeID == state.routes.count - 1, case .ticket = state.routes.last?.screen diff --git a/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift index 55b1fdaf..a2cb85e4 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift @@ -28,7 +28,11 @@ struct ClassCoordinatorFeedbackTests { removed.report = MockClassReport.figma removed.sourceViewState = .ready var state = ClassCoordinator.State() - state.routes.push(.detail(.init(room: room))) + state.routes.push(.detail(withDependencies { + $0.uuid = .incrementing + } operation: { + ClassDetailFeature.State(room: room) + })) state.routes.push(.report(first)) state.routes.push(.report(removed)) let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { @@ -69,7 +73,11 @@ struct ClassCoordinatorFeedbackTests { original.report = MockClassReport.figma original.sourceViewState = .ready var state = ClassCoordinator.State() - state.routes.push(.detail(.init(room: room))) + state.routes.push(.detail(withDependencies { + $0.uuid = .incrementing + } operation: { + ClassDetailFeature.State(room: room) + })) state.routes.push(.report(original)) let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.classResultUseCase = generator @@ -116,7 +124,11 @@ struct ClassCoordinatorFeedbackTests { original.dashboard = MockClassOwnerDashboard.figma original.sourceViewState = .ready var state = ClassCoordinator.State() - state.routes.push(.detail(.init(room: room))) + state.routes.push(.detail(withDependencies { + $0.uuid = .incrementing + } operation: { + ClassDetailFeature.State(room: room) + })) state.routes.push(.ownerDashboard(original)) let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.classResultUseCase = generator @@ -243,6 +255,13 @@ private actor SuspendedFeedbackGenerator: ClassResultInterface { throw ClassAIFeedbackError.generationFailed } + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + throw ClassAIFeedbackError.temporarilyUnavailable + } + private func suspend( request: @escaping @Sendable (CheckedContinuation) -> Request ) async throws -> T { diff --git a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift index dae174ab..b849ffe8 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift @@ -1364,6 +1364,22 @@ struct ClassCoordinatorTests { #expect(store.state.routes.count == 2) } + @Test + func 소개_화면이_맨_위가_아니면_늦게_온_만들기_요청을_무시한다() async { + var state = ClassCoordinator.State() + state.routes.push(.myClasses(withDependencies { + $0.uuid = .incrementing + } operation: { + MyClassFeature.State() + })) + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 0, action: .intro(.delegate(.create))))) + + #expect(store.state.routes.count == 2) + } + @Test func 스와이프로_멤버_보고서_경로를_제거하면_진행중인_로드를_취소한다() async { let room = ClassRoom.mockClosedSociety @@ -1386,6 +1402,74 @@ struct ClassCoordinatorTests { #expect(store.state.routes.count == 2) } + @Test + func 스와이프로_내가_남긴_의견_경로를_제거하면_진행중인_조회를_취소한다() async { + let room = ClassRoom.mockClosedSociety + let useCase = SuspendedClassUseCase() + var opinions = ClassMyOpinionsFeature.State() + opinions.classID = room.id + var state = ClassCoordinator.State() + state.routes.push(.detail(testDetailState(room: room))) + state.routes.push(.myOpinions(opinions)) + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.classResultUseCase = useCase + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .myOpinions(.async(.fetchReplyPage(cursor: nil)))))) + await useCase.waitForStarts(1) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(await useCase.cancelledRequestIDs == [1]) + #expect(store.state.routes.count == 2) + } + + @Test + func 스와이프로_멤버_보고서_경로를_제거하면_진행중인_인사이트_조회를_취소한다() async { + let room = ClassRoom.mockClosedSociety + let useCase = SuspendedClassUseCase() + var state = ClassCoordinator.State() + state.routes.push(.detail(testDetailState(room: room))) + state.routes.push(.report(.init(room: room, instanceID: UUID()))) + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.classResultUseCase = useCase + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .report(.async(.fetchInsight))))) + await useCase.waitForStarts(1) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(await useCase.cancelledRequestIDs == [1]) + #expect(store.state.routes.count == 2) + } + + @Test + func 스와이프로_운영자_보고서_경로를_제거하면_진행중인_내_활동_조회를_취소한다() async { + let room = ClassRoom.mocks[0] + let useCase = SuspendedClassUseCase() + var state = ClassCoordinator.State() + state.routes.push(.detail(testDetailState(room: room))) + state.routes.push(.ownerDashboard(.init(room: room, instanceID: UUID()))) + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.classResultUseCase = useCase + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .ownerDashboard(.async(.fetchMyActivity))))) + await useCase.waitForStarts(1) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(await useCase.cancelledRequestIDs == [1]) + #expect(store.state.routes.count == 2) + } + @Test func 운영자_보고서가_루트로_제거되면_진행중인_로드를_취소한다() async { let room = ClassRoom.mocks[0] @@ -1669,6 +1753,16 @@ struct ClassParticipantPushTests { let secondEnd = try #require("2027.04.30".date(as: .yearMonthDayDotted)) let clock = TestClock() let originalID = UUID(400) + let preview = ClassTicketPreview(capacity: 30, registerBy: secondEnd, usageMonths: 6, registeredByMe: false) + let secondTicket = ClassTicket( + id: 5, + capacity: 30, + status: .active, + activatedAt: secondStart, + expiresAt: secondEnd, + remainingDays: 180, + readGraceEndsAt: secondEnd + ) var registration = ClassTicketFeature.State(instanceID: originalID) registration.phase = .registered registration.registrationStartDate = firstStart @@ -1680,6 +1774,7 @@ struct ClassParticipantPushTests { $0.uuid = .incrementing $0.continuousClock = clock $0.date = .constant(secondStart) + $0.classTicketUseCase = ClockTicketUseCase(clock: clock, preview: preview, ticket: secondTicket) } await store.send(.router(.routeAction( @@ -1700,7 +1795,9 @@ struct ClassParticipantPushTests { id: 2, action: .ticket(.delegate(.addRegistrationRequested)) ))) { - $0.routes.push(.ticket(.init(instanceID: UUID(1)))) + var registration = ClassTicketFeature.State(instanceID: UUID(1)) + registration.shouldCheckExistingTicket = false + $0.routes.push(.ticket(registration)) $0.ticketInstanceIDs = [originalID, UUID(0), UUID(1)] } await store.send(.router(.routeAction( @@ -1719,10 +1816,28 @@ struct ClassParticipantPushTests { ticket.isChecking = true $0.routes[screenAt: 3] = .ticket(ticket) } + // 현재 계약: 코드 확인은 미리보기만 보여 주고, 등록하기에서 등록한다. + await clock.advance(by: .milliseconds(350)) + await store.receive(\.router.routeAction) { + guard case var .ticket(ticket) = $0.routes[3].screen else { return } + ticket.isChecking = false + ticket.preview = preview + ticket.phase = .preview + $0.routes[screenAt: 3] = .ticket(ticket) + } + await store.send(.router(.routeAction( + id: 3, + action: .ticket(.view(.confirmTapped)) + ))) { + guard case var .ticket(ticket) = $0.routes[3].screen else { return } + ticket.isChecking = true + $0.routes[screenAt: 3] = .ticket(ticket) + } await clock.advance(by: .milliseconds(350)) await store.receive(\.router.routeAction) { guard case var .ticket(ticket) = $0.routes[3].screen else { return } ticket.isChecking = false + ticket.ticket = secondTicket ticket.phase = .registered ticket.registrationStartDate = secondStart ticket.expirationDate = secondEnd @@ -1734,8 +1849,10 @@ struct ClassParticipantPushTests { else { return } overview.registrationStartDate = secondStart overview.expirationDate = secondEnd + overview.ticket = secondTicket original.registrationStartDate = secondStart original.expirationDate = secondEnd + original.ticket = secondTicket $0.routes[screenAt: 2] = .ticket(overview) $0.routes[screenAt: 1] = .ticket(original) } @@ -1769,6 +1886,76 @@ struct ClassParticipantPushTests { #expect(original.periodText == "2026.11.01 – 2027.04.30") } + @Test + func 개요_위_등록_화면에서_사용_중인_내_이용권을_다시_입력하면_개요로_돌아간다() async { + let ticket = ClassTicket( + id: 9, + capacity: 30, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_800_000_000), + expiresAt: Date(timeIntervalSince1970: 1_815_000_000), + remainingDays: 170, + readGraceEndsAt: Date(timeIntervalSince1970: 1_817_592_000) + ) + var overview = ClassTicketFeature.State(instanceID: UUID(310)) + overview.phase = .overview + var registration = ClassTicketFeature.State(instanceID: UUID(311)) + registration.shouldCheckExistingTicket = false + var state = ClassCoordinator.State() + state.routes.push(.ticket(overview)) + state.routes.push(.ticket(registration)) + state.ticketInstanceIDs = [UUID(310), UUID(311)] + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("기존 이용권 개요로 돌아가지 않았습니다") + return + } + #expect(current.instanceID == UUID(310)) + #expect(current.ticket == ticket) + } + + @Test + func 바로_연_이용권_화면에서_사용_중인_내_이용권을_다시_입력하면_개요로_바꾸고_안내_중에는_탭바를_숨긴다() async { + let ticket = ClassTicket( + id: 9, + capacity: 30, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_800_000_000), + expiresAt: Date(timeIntervalSince1970: 1_815_000_000), + remainingDays: 170, + readGraceEndsAt: Date(timeIntervalSince1970: 1_817_592_000) + ) + var registration = ClassTicketFeature.State(instanceID: UUID(312)) + registration.code = "PICKE30" + var state = ClassCoordinator.State() + state.routes.push(.ticket(registration)) + state.ticketInstanceIDs = [UUID(312)] + #expect(state.showsTabBar) + var alerted = registration + alerted.customAlert = .ticketInvalidCode() + var alertedState = state + alertedState.routes[screenAt: 1] = .ticket(alerted) + #expect(!alertedState.showsTabBar) + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 1, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("이용권 화면이 사라졌습니다") + return + } + #expect(current.instanceID == UUID(312)) + #expect(current.phase == .overview) + #expect(current.ticket == ticket) + } + @Test func 이용권_확인_중_스와이프로_경로가_제거되면_응답도_취소된다() async { let clock = TestClock() @@ -1781,6 +1968,7 @@ struct ClassParticipantPushTests { let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.continuousClock = clock $0.date = .constant(Date(timeIntervalSince1970: 0)) + $0.classTicketUseCase = ClockTicketUseCase(clock: clock) } await store.send(.router(.routeAction(id: 1, action: .ticket(.view(.checkTapped))))) { @@ -1869,6 +2057,7 @@ struct ClassParticipantPushTests { let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.continuousClock = clock $0.date = .constant(start) + $0.classTicketUseCase = ClockTicketUseCase(clock: clock) } await store.send(.router(.routeAction(id: 3, action: .ticket(.view(.checkTapped))))) { @@ -2762,6 +2951,33 @@ struct ChatPlayerLifecycleTests { } } +/// 코드 확인·등록이 테스트 시계 350ms 뒤에 끝나는 이용권 대역. +private struct ClockTicketUseCase: ClassTicketInterface { + let clock: TestClock + var preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + var ticket: ClassTicket? + + func previewTicket(code _: String) async throws -> ClassTicketPreview { + try await clock.sleep(for: .milliseconds(350)) + return preview + } + + func registerTicket(code _: String) async throws -> ClassTicket { + try await clock.sleep(for: .milliseconds(350)) + guard let ticket else { throw ClassTicketError.invalidCode } + return ticket + } + + func fetchMyTicket() async throws -> ClassTicket { + throw ClassTicketError.notFound + } +} + private struct SlowBattleUseCase: ClassBattleInterface { let tracker: SlowBattleRequestTracker private let fallback = MockClassRepository() @@ -2777,6 +2993,14 @@ private struct SlowBattleUseCase: ClassBattleInterface { try await fallback.submitPreVote(classID: classID, optionID: optionID) } + func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + try await fallback.submitMidVote(classID: classID, checkpointID: checkpointID, optionID: optionID) + } + func completeTTS(classID _: Int) async throws { await tracker.markStarted() do { @@ -2999,11 +3223,20 @@ private struct FixedClassResultUseCase: ClassResultInterface { func retryInsight(roomID _: Int) async throws { throw ClassError.unknown("이 테스트는 인사이트 재시도를 사용하지 않습니다.") } + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + throw ClassError.unknown("이 테스트는 내 활동 조회를 사용하지 않습니다.") + } } private actor SuspendedClassUseCase: ClassResultInterface { private var nextRequestID = 0 private var requests: [Int: CheckedContinuation] = [:] + private var activityRequests: [Int: CheckedContinuation] = [:] + private var insightRequests: [Int: CheckedContinuation] = [:] private var startedCount = 0 private var startWaiters: [(Int, CheckedContinuation)] = [] private(set) var cancelledRequestIDs: [Int] = [] @@ -3033,6 +3266,18 @@ private actor SuspendedClassUseCase: ClassResultInterface { } } + private func cancelInsight(_ id: Int) { + guard let continuation = insightRequests.removeValue(forKey: id) else { return } + cancelledRequestIDs.append(id) + continuation.resume(throwing: CancellationError()) + } + + private func cancelActivity(_ id: Int) { + guard let continuation = activityRequests.removeValue(forKey: id) else { return } + cancelledRequestIDs.append(id) + continuation.resume(throwing: CancellationError()) + } + private func cancelReport(_ id: Int) { guard let continuation = requests.removeValue(forKey: id) else { return } cancelledRequestIDs.append(id) @@ -3056,13 +3301,36 @@ private actor SuspendedClassUseCase: ClassResultInterface { } func fetchInsight(roomID _: Int) async throws -> ClassAIInsight { - throw ClassError.network("이 테스트는 인사이트 조회를 사용하지 않습니다.") + let id = nextID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + insightRequests[id] = continuation + didStart() + } + } onCancel: { + Task { await self.cancelInsight(id) } + } } func retryInsight(roomID _: Int) async throws { throw ClassError.network("이 테스트는 인사이트 재시도를 사용하지 않습니다.") } + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + let id = nextID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + activityRequests[id] = continuation + didStart() + } + } onCancel: { + Task { await self.cancelActivity(id) } + } + } + private static var fixture: ClassResult { ClassResult( classID: ClassRoom.mockClosedSociety.id, diff --git a/Projects/App/Tests/Sources/ClassFigmaParityCaptureTests.swift b/Projects/App/Tests/Sources/ClassFigmaParityCaptureTests.swift new file mode 100644 index 00000000..dc88c5d2 --- /dev/null +++ b/Projects/App/Tests/Sources/ClassFigmaParityCaptureTests.swift @@ -0,0 +1,258 @@ +// +// ClassFigmaParityCaptureTests.swift +// PickeTests +// +// Created by Picke on 11/2/26. +// + +import ClassDomainInterface +import ComposableArchitecture +import FeatureAssembly +@testable import Picke +import PickeDesignKit +import SwiftUI +import UIKit +import XCTest + +/// Run explicitly with PICKE_CLASS_VISUAL_AUDIT=1. Captures the real Class route +/// screens at 1x and the Figma frame size so they can be diffed against the +/// FigmaClassAudit plugin export. These images are evidence, not snapshot assertions. +final class ClassFigmaParityCaptureTests: XCTestCase { + @MainActor + func testCaptureClassFigmaParityFrames() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture Class Figma parity frames") + } + let directory = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("ClassFigmaParity", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let phone = CGSize(width: 375, height: 812) + try await save(flowView(step: .none, selectedOptionID: 2), size: phone, name: "13337-56744-pre-vote", in: directory) + try await save( + flowView(step: .ttsListening, selectedOptionID: 2), + size: phone, + name: "13337-56966-tts", + in: directory + ) + try await save( + flowView(step: .postVote, selectedOptionID: 2), + size: phone, + name: "13337-57185-final-vote", + in: directory + ) + try await save( + flowView(step: .completed, selectedOptionID: 2), + size: phone, + name: "13384-57726-opinion", + in: directory + ) + try await save(reportView(), size: CGSize(width: 402, height: 1090), name: "13757-17445-result", in: directory) + } +} + +private extension ClassFigmaParityCaptureTests { + var room: ClassRoom { + let source = ClassRoom.mockClosedSociety + return ClassRoom( + id: source.id, + name: source.name, + joinCode: source.joinCode, + battle: source.battle, + deadline: source.deadline, + memberCount: source.memberCount, + role: .member, + status: .open, + allowsAnonymousOpinion: source.allowsAnonymousOpinion, + requiresComment: source.requiresComment, + ownerParticipates: source.ownerParticipates, + memberCapacity: source.memberCapacity + ) + } + + @MainActor + func flowView( + step: ClassBattleStep, + selectedOptionID: Int + ) -> AnyView { + var state = ClassBattleFlowFeature.State() + state.room = room + state.viewState = .loaded + // A pending load request keeps onAppear from replacing the seeded state with network calls. + state.loadRequestID = UUID() + state.selectedOptionID = selectedOptionID + state.session = ClassBattleSession( + classID: room.id, + myRole: .member, + closeAt: Date(timeIntervalSince1970: 1_790_154_000), + closed: false, + commentRequired: true, + canParticipate: true, + participationCompleted: false, + battle: ClassBattleDetail( + id: 10, + title: "촉법소년 연령을 낮춰야 할까?", + summary: "날로 잔혹해지는 청소년 범죄, 당신은 강력한 처벌을 원하십니까, 아니면 기회와 교정을 원하십니까?", + thumbnailURL: nil, + description: "날로 잔혹해지는 청소년 범죄, 당신은 강력한 처벌을 원하십니까, 아니면 기회와 교정을 원하십니까?", + shareURL: nil, + userVoteStatus: nil, + currentStep: step, + participantsCount: 28, + audioDuration: 268, + options: [ + .init(id: 1, label: "A", title: "예술이다", stance: "예술이다", representative: "사르트르", imageURL: nil), + .init(id: 2, label: "B", title: "변기는 변기다", stance: "변기는 변기다", representative: "플라톤", imageURL: nil), + ] + ), + midVoteEnabled: false + ) + state.hasPublishedPerspective = false + state.voteHistory = ClassVoteHistory( + battleTitle: "촉법소년 연령을 낮춰야 할까?", + preVote: .init(optionID: 1, label: "A", title: "예술이다"), + postVote: .init(optionID: 2, label: "B", title: "변기는 변기다"), + status: step, + opinionChanged: true + ) + state.isAudioReady = true + state.isPlaying = true + state.audioDuration = 268 + state.currentTime = 127 + state.transcript = [ + .init(id: 0, startTime: 0, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "범죄의 잔혹함과 지능화에 나이는 더 이상 핑계가 될 수 없습니다!", side: "A"), + .init(id: 1, startTime: 10, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "'촉법소년'이라는 제도가 오히려 아이들에게 법을 비웃고 범죄를 저지를 면죄부가 되고 있습니다.", side: "A"), + .init(id: 2, startTime: 20, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "피해자의 눈물을 닦아주고 사법 정의를 세우려면 촉법 연령을 낮춰 단호하게 처벌해야 합니다.", side: "A"), + .init(id: 3, startTime: 40, speakerName: "루소", speakerImageURL: nil, + text: "아렌트 선생, 청소년은 아직 뇌와 도덕성이 성숙하지 못한 존재입니다!", side: "B"), + .init(id: 4, startTime: 60, speakerName: "루소", speakerImageURL: nil, + text: "성인과 똑같은 엄벌의 잣대를 대는 것은 사회가 아이들의 선도 가능성을 포기하는 것과 같습니다.", side: "B"), + .init(id: 5, startTime: 80, speakerName: "루소", speakerImageURL: nil, + text: "전과자라는 낙인을 찍어 감옥에 보내는 대신, 교육과 환경 개선을 통해 사회로 돌아오게 돕는 것이 국가의 의무입니다.", + side: "B"), + .init(id: 6, startTime: 120, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "처벌받지 않는 악은 반드시 재범으로 이어집니다.", side: "A"), + ] + state.voteStats = ClassVoteStats( + options: [ + .init(id: 1, title: "예술이다", imageURL: nil, voteCount: 119, ratio: 59.5), + .init(id: 2, title: "변기는 변기다", imageURL: nil, voteCount: 81, ratio: 40.5), + ], + totalCount: 200, + updatedAt: .now + ) + let author = ClassPerspectiveAuthor( + userTag: "raccoon", + nickname: "사유하는 라쿤", + characterType: nil, + characterImageURL: nil + ) + let option = ClassPerspectiveOption(id: 2, label: "B", title: "변기는 변기다", stance: "변기는 변기다") + state.perspectives = (0 ..< 3).map { index in + ClassPerspectiveItem( + id: 100 + index, + author: author, + option: option, + content: "제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요. 네덜란드 사례를 보면 우려가 현실이 되고 있죠. 제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요.", + likeCount: 1340, + commentCount: 23, + isLiked: false, + isMyPerspective: false, + status: nil, + createdAt: Date().addingTimeInterval(-120) + ) + } + let store = Store(initialState: state) { ClassBattleFlowFeature() } + return AnyView(ClassBattleFlowView(store: store)) + } + + @MainActor + func reportView() -> AnyView { + let source = ClassRoom.mockClosedSociety + let owner = ClassRoom( + id: source.id, + name: source.name, + joinCode: source.joinCode, + battle: source.battle, + deadline: source.deadline, + memberCount: source.memberCount, + role: .owner, + status: .closed, + allowsAnonymousOpinion: source.allowsAnonymousOpinion, + requiresComment: source.requiresComment, + ownerParticipates: source.ownerParticipates, + memberCapacity: source.memberCapacity + ) + var state = ClassReportFeature.State(room: owner, instanceID: UUID()) + state.sourceViewState = .ready + state.insight = ClassAIInsight( + text: "처음에는 뫼르소의 태도를 비난할 수 있다는 의견이 많았지만, 대화 후에는 행동과 감정을 분리해서 봐야 한다는 의견이 늘어났어요.", + highlights: [ + .init(content: "슬픔을 겉으로 드러내지 않는다고 해서 타인을 사랑하지 않는다고 판단할 수는 없다고 생각해요."), + .init(content: "감정은 개인의 자유지만, 사회적 관계에서는 최소한의 공감 표현도 필요하다고 생각해요."), + ] + ) + state.classResult = ClassResult( + classID: owner.id, + closed: true, + closeAt: owner.deadline, + stats: .init( + activeMemberCount: 32, + completedCount: 28, + participationRate: 87.5, + commentWriterCount: 24, + commentRate: 75, + postVoteCount: 28, + options: [ + .init(id: 1, title: "낮춰야한다", preVoteCount: 18, postVoteCount: 18, postVoteRatio: 64), + .init(id: 2, title: "교정이 우선이다", preVoteCount: 10, postVoteCount: 10, postVoteRatio: 36), + ] + ), + myReportState: nil, + insightState: "SUCCEEDED" + ) + let store = Store(initialState: state) { ClassReportFeature() } + return AnyView(ClassReportView(store: store)) + } + + @MainActor + func save( + _ view: AnyView, + size: CGSize, + name: String, + in directory: URL + ) async throws { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: view.environment(\.colorScheme, .light)) + window.rootViewController = host + window.frame = CGRect(origin: .zero, size: size) + window.makeKeyAndVisible() + // A window shorter than the screen sits above the home indicator, so mirror a device bottom safe area. + host.additionalSafeAreaInsets.bottom = max(0, 34 - window.safeAreaInsets.bottom) + defer { window.isHidden = true } + host.view.frame = window.bounds + host.view.layoutIfNeeded() + // Report charts animate after data arrives. + try await Task.sleep(for: .milliseconds(1200)) + host.view.layoutIfNeeded() + CATransaction.flush() + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent(name + ".png")) + let attachment = XCTAttachment(image: image) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift index f05f37c6..eaaae8c4 100644 --- a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift @@ -435,6 +435,68 @@ final class ClassFlowVisualAuditTests: XCTestCase { } } + @MainActor + func testCaptureServerResultParticipationAndStance() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture the server result") + } + let source = ClassRoom.mockClosedSociety + let room = ClassRoom( + id: source.id, + name: source.name, + joinCode: source.joinCode, + battle: source.battle, + deadline: source.deadline, + memberCount: source.memberCount, + role: .owner, + status: .closed, + allowsAnonymousOpinion: source.allowsAnonymousOpinion, + requiresComment: source.requiresComment, + ownerParticipates: source.ownerParticipates, + memberCapacity: source.memberCapacity + ) + var state = ClassReportFeature.State(room: room, instanceID: UUID()) + state.sourceViewState = .ready + state.insight = ClassAIInsight(text: "처음에는 민로스의 태도를 비난할 수 있다는 의견이 많았지만, 대화 후에는 형평과 감정을 분리해 봐야 한다는 의견이 늘어났어요.") + state.classResult = ClassResult( + classID: room.id, + closed: true, + closeAt: room.deadline, + stats: .init( + activeMemberCount: 32, + completedCount: 28, + participationRate: 87.5, + commentWriterCount: 24, + commentRate: 75, + postVoteCount: 28, + options: [ + .init(id: 1, title: "낮춰야한다", preVoteCount: 18, postVoteCount: 18, postVoteRatio: 64), + .init(id: 2, title: "교정이 우선이다", preVoteCount: 10, postVoteCount: 10, postVoteRatio: 36) + ] + ), + myReportState: nil, + insightState: "SUCCEEDED" + ) + let store = Store(initialState: state) { ClassReportFeature() } + let (top, bottom) = try await capture( + AnyView(ClassReportView(store: store)), + ready: nil, + includeScrollBottom: true, + viewport: CGSize(width: 402, height: 874) + ) + for (name, image) in [("top", top), ("bottom", try XCTUnwrap(bottom))] { + let attachment = XCTAttachment(image: image) + attachment.name = "class-server-result-402-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + } + let topText = try recognizedAuditText(in: top, name: "class-server-result-402-top") + let bottomText = try recognizedAuditText(in: XCTUnwrap(bottom), name: "class-server-result-402-bottom") + XCTAssertTrue(topText.contains("클래스참여현황"), "Participation card missing: \(topText)") + XCTAssertTrue(topText.contains("참여한멤버"), "Participation caption missing: \(topText)") + XCTAssertTrue(bottomText.contains("입장분포"), "Stance card missing: \(bottomText)") + } + @MainActor func testClassReportLayoutFooters() async throws { guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { @@ -3091,6 +3153,13 @@ final class ClassFlowVisualAuditTests: XCTestCase { } func retryInsight(roomID _: Int) async throws {} + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + throw ClassAIFeedbackError.temporarilyUnavailable + } } private static func visualResultUseCase() -> VisualResultUseCase { VisualResultUseCase() } diff --git a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift index 5b166307..7b6640fd 100644 --- a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift @@ -15,7 +15,9 @@ final class ClassTicketVisualAuditTests: XCTestCase { throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture ticket screens") } - let entry = ClassTicketFeature.State() + var entry = ClassTicketFeature.State() + // 진입 조회가 실패하면 토스트 후 닫히므로 입력 화면 캡처에서는 조회하지 않는다. + entry.shouldCheckExistingTicket = false var registered = ClassTicketFeature.State() registered.phase = .registered diff --git a/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift b/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift index 2560e66b..4755b1da 100644 --- a/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift @@ -52,6 +52,45 @@ struct MainTabCoordinatorTests { await store.finish() } + @Test + func 클래스_만료_안내의_등록하기는_마이_탭_코드_입력_화면을_연다() async { + var state = MainTabCoordinator.State() + state.classState.routes.push(.myClasses(withDependencies { + $0.uuid = .incrementing + } operation: { + MyClassFeature.State() + })) + let store = TestStore(initialState: state) { + MainTabCoordinator() + } withDependencies: { + $0.analyticsUseCase.track = { _ in } + } + store.exhaustivity = .off + + await store.send(.classroom(.router(.routeAction(id: 0, action: .intro(.delegate(.registerTicket)))))) + + #expect(store.state.selectedTab == MainTabCoordinator.Tab.myPage.rawValue) + #expect(store.state.classState.routes.count == 1) + guard case let .ticket(ticket)? = store.state.myPageState.routes.last?.screen else { + Issue.record("마이 탭에 이용권 화면이 열리지 않았습니다") + return + } + #expect(ticket.shouldCheckExistingTicket == false) + #expect(ticket.phase == .entry) + } + + @Test + func 마이_탭_이용권_화면은_안내가_떠_있을_때만_탭바를_숨긴다() { + var state = MainTabCoordinator.State(selectedTab: MainTabCoordinator.Tab.myPage.rawValue) + var ticket = ClassTicketFeature.State() + state.myPageState.routes.push(.ticket(ticket)) + #expect(!state.shouldHideTabBar) + + ticket.customAlert = .ticketInvalidCode() + state.myPageState.routes[screenAt: 1] = .ticket(ticket) + #expect(state.shouldHideTabBar) + } + @Test func 탭_변경은_한번만_기록하고_재선택과_잘못된_탭은_기록하지_않는다() async { let events = LockIsolated<[AnalyticsButton]>([]) diff --git a/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift b/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift index 3c280982..405854a3 100644 --- a/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift @@ -1,3 +1,4 @@ +import ClassDomainInterface import ComposableArchitecture import FeatureAssembly import Foundation @@ -115,4 +116,103 @@ struct ProfileTicketCoordinatorTests { #expect(updated.expirationDate == laterEnd) await store.finish() } + + @Test + func 개요_위에서_사용_중인_내_이용권을_다시_입력하면_기존_개요로_돌아간다() async { + let ticket = profileActiveTicket() + var overview = ClassTicketFeature.State(instanceID: UUID(10)) + overview.phase = .overview + var registration = ClassTicketFeature.State(instanceID: UUID(11)) + registration.shouldCheckExistingTicket = false + var state = Picke.ProfileCoordinator.State() + state.routes.push(.ticket(overview)) + state.routes.push(.ticket(registration)) + let store = TestStore(initialState: state) { Picke.ProfileCoordinator() } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("기존 이용권 개요로 돌아가지 않았습니다") + return + } + #expect(current.instanceID == UUID(10)) + #expect(current.phase == .overview) + #expect(current.ticket == ticket) + } + + @Test + func 바로_연_등록_화면에서_사용_중인_내_이용권을_다시_입력하면_그_화면을_개요로_바꾼다() async { + let ticket = profileActiveTicket() + var registration = ClassTicketFeature.State(instanceID: UUID(12)) + registration.code = "PICKE30" + var state = Picke.ProfileCoordinator.State() + state.routes.push(.ticket(registration)) + let store = TestStore(initialState: state) { Picke.ProfileCoordinator() } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 1, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("이용권 화면이 사라졌습니다") + return + } + #expect(current.instanceID == UUID(12)) + #expect(current.phase == .overview) + #expect(current.ticket == ticket) + #expect(current.preview == nil) + } + + @Test + func 이용권_화면을_닫으면_진입_조회도_취소한다() async { + let ticketID = UUID(13) + var state = Picke.ProfileCoordinator.State() + state.routes.push(.ticket(ClassTicketFeature.State(instanceID: ticketID))) + state.ticketInstanceIDs = [ticketID] + let store = TestStore(initialState: state) { Picke.ProfileCoordinator() } withDependencies: { + $0.uuid = .incrementing + $0.classTicketUseCase = NeverEndingTicketUseCase() + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 1, action: .ticket(.view(.onAppear))))) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(store.state.routes.count == 1) + } +} + +private func profileActiveTicket() -> ClassTicket { + ClassTicket( + id: 7, + capacity: 30, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_800_000_000), + expiresAt: Date(timeIntervalSince1970: 1_815_000_000), + remainingDays: 170, + readGraceEndsAt: Date(timeIntervalSince1970: 1_817_592_000) + ) +} + +/// 내 이용권 조회가 취소될 때까지 끝나지 않는 대역. +private struct NeverEndingTicketUseCase: ClassTicketInterface { + func previewTicket(code _: String) async throws -> ClassTicketPreview { + throw ClassTicketError.invalidCode + } + + func registerTicket(code _: String) async throws -> ClassTicket { + throw ClassTicketError.invalidCode + } + + func fetchMyTicket() async throws -> ClassTicket { + try await Task.sleep(for: .seconds(60)) + throw ClassTicketError.notFound + } } diff --git a/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift b/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift index eb253a88..1a52d3d4 100644 --- a/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift +++ b/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift @@ -29,10 +29,14 @@ struct PickeEventMonitor: EventMonitor { ) { let urlRequest = response.request let method = urlRequest?.httpMethod ?? "?" - let url = urlRequest?.url?.absoluteString ?? "?" + // Query values can contain invite or ticket codes; keep only the route in logs. + let url = urlRequest?.url?.path ?? "?" let status = response.response?.statusCode ?? -1 let milliseconds = Int((response.metrics?.taskInterval.duration ?? 0) * 1000) let isFailure = response.error != nil + let isSensitiveRoute = url.hasPrefix("/api/v1/auth/") || + url.hasPrefix("/api/v1/classes") || + url.hasPrefix("/api/v1/class-tickets") var lines = ["\(isFailure ? "❌" : "✅") \(method) \(url) → \(status) (\(milliseconds)ms)"] @@ -41,7 +45,7 @@ struct PickeEventMonitor: EventMonitor { lines.append(" · Request Headers\n\(format(headers))") } if let body = urlRequest?.httpBody, !body.isEmpty { - lines.append(" · Request Body\n\(indent(prettyBody(body)))") + lines.append(" · Request Body\n\(isSensitiveRoute ? " " : indent(prettyBody(body)))") } // ── 응답 ── @@ -58,7 +62,7 @@ struct PickeEventMonitor: EventMonitor { } } if let data = response.data, !data.isEmpty { - lines.append(" · Response Body\n\(indent(prettyBody(data)))") + lines.append(" · Response Body\n\(isSensitiveRoute ? " " : indent(prettyBody(data)))") } let message = lines.joined(separator: "\n") @@ -118,7 +122,11 @@ private extension PickeEventMonitor { func format(_ headers: [String: String]) -> String { headers .sorted { $0.key < $1.key } - .map { " \($0.key): \($0.value)" } + .map { key, value in + let shouldRedact = ["authorization", "cookie", "set-cookie"] + .contains(key.lowercased()) || key.lowercased().contains("token") + return " \(key): \(shouldRedact ? "" : value)" + } .joined(separator: "\n") } diff --git a/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift index 09f251cc..478ab7ac 100644 --- a/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift @@ -78,6 +78,8 @@ public struct ScenarioScriptDTO: Decodable { } public struct ScenarioInteractiveOptionDTO: Decodable { - public let label: String - public let nextNodeId: Int + public let optionId: Int? + public let label: String? + public let title: String? + public let nextNodeId: Int? } diff --git a/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift index 07840d0c..da96e80b 100644 --- a/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift @@ -50,7 +50,7 @@ public extension ScenarioNodeDTO { audioDuration: audioDuration, autoNextNodeId: autoNextNodeId, scripts: scripts.map { $0.toDomain() }, - interactiveOptions: (interactiveOptions ?? []).map { $0.toDomain() } + interactiveOptions: (interactiveOptions ?? []).compactMap { $0.toDomain() } ) } } @@ -68,7 +68,16 @@ public extension ScenarioScriptDTO { } public extension ScenarioInteractiveOptionDTO { - func toDomain() -> ScenarioInteractiveOption { - ScenarioInteractiveOption(label: label, nextNodeId: nextNodeId) + func toDomain() -> ScenarioInteractiveOption? { + guard let nextNodeId else { return nil } + let displayLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedLabel = (displayLabel?.isEmpty == false ? displayLabel : nil) ?? displayTitle ?? "선택지" + return ScenarioInteractiveOption( + label: resolvedLabel, + nextNodeId: nextNodeId, + optionID: optionId, + title: displayTitle + ) } } diff --git a/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift b/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift index 7323e98f..4fac9844 100644 --- a/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift +++ b/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift @@ -6,6 +6,7 @@ // @testable import BattleDomain +import Foundation import Testing struct BattleDomainTests { @@ -13,4 +14,25 @@ struct BattleDomainTests { func battleDomainExample() { #expect(true) } + + @Test + func interactiveOptionPreservesServerID() throws { + let json = Data(#"{"optionId":9001,"label":"유죄","nextNodeId":802}"#.utf8) + let option = try #require(JSONDecoder().decode(ScenarioInteractiveOptionDTO.self, from: json).toDomain()) + + #expect(option.optionID == 9001) + #expect(option.label == "유죄") + #expect(option.nextNodeId == 802) + } + + @Test + func interactiveOptionFallsBackToTitleAndSkipsMissingNextNode() throws { + let titleOnly = Data(#"{"optionId":9001,"title":"피고인의 사정을 듣는다","nextNodeId":802}"#.utf8) + let option = try #require(JSONDecoder().decode(ScenarioInteractiveOptionDTO.self, from: titleOnly).toDomain()) + #expect(option.label == "피고인의 사정을 듣는다") + #expect(option.title == "피고인의 사정을 듣는다") + + let missingNext = Data(#"{"optionId":9002,"label":"유죄","title":"유죄를 선택한다"}"#.utf8) + #expect(try JSONDecoder().decode(ScenarioInteractiveOptionDTO.self, from: missingNext).toDomain() == nil) + } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift index ec3b1e2e..dbf211d4 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift @@ -15,6 +15,7 @@ public struct ClassAIPersonalFeedback: Equatable, Sendable { public let coverage: String? public let excludedCount: Int public let preOption: ClassAIFeedbackVoteChoice? + public let midVotes: [ClassMidVoteChoice] public let postOption: ClassAIFeedbackVoteChoice? public let perspectiveCount: Int public let commentCount: Int @@ -37,6 +38,7 @@ public struct ClassAIPersonalFeedback: Equatable, Sendable { coverage: String? = nil, excludedCount: Int = 0, preOption: ClassAIFeedbackVoteChoice? = nil, + midVotes: [ClassMidVoteChoice] = [], postOption: ClassAIFeedbackVoteChoice? = nil, perspectiveCount: Int = 0, commentCount: Int = 0 @@ -50,6 +52,7 @@ public struct ClassAIPersonalFeedback: Equatable, Sendable { self.coverage = coverage self.excludedCount = excludedCount self.preOption = preOption + self.midVotes = midVotes self.postOption = postOption self.perspectiveCount = perspectiveCount self.commentCount = commentCount diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift index d8d7951f..255b82dd 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift @@ -9,6 +9,7 @@ public struct ClassBattleSession: Equatable, Sendable { public let canParticipate: Bool public let participationCompleted: Bool public let battle: ClassBattleDetail + public let midVoteEnabled: Bool public init( classID: Int, @@ -18,7 +19,8 @@ public struct ClassBattleSession: Equatable, Sendable { commentRequired: Bool, canParticipate: Bool, participationCompleted: Bool, - battle: ClassBattleDetail + battle: ClassBattleDetail, + midVoteEnabled: Bool = false ) { self.classID = classID self.myRole = myRole @@ -28,5 +30,6 @@ public struct ClassBattleSession: Equatable, Sendable { self.canParticipate = canParticipate self.participationCompleted = participationCompleted self.battle = battle + self.midVoteEnabled = midVoteEnabled } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift index 19730900..df3cbeb8 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift @@ -8,6 +8,11 @@ public enum ClassBattleError: LocalizedError, Equatable, Sendable { case preVoteLocked case invalidOption case ttsUnavailable + case ttsIncomplete + case midVoteUnavailable + case invalidMidVoteOption + case midVoteLocked + case midVoteRequired case voteNotFound case preVoteRequired case alreadySubmitted @@ -26,6 +31,11 @@ public enum ClassBattleError: LocalizedError, Equatable, Sendable { case .preVoteLocked: return "청취 또는 토론을 시작한 뒤에는 사전투표를 바꿀 수 없어요." case .invalidOption: return "이 배틀의 선택지가 아니에요." case .ttsUnavailable: return "이 클래스에서는 TTS 청취를 사용할 수 없어요." + case .ttsIncomplete: return "TTS 청취를 완료한 뒤 투표해 주세요." + case .midVoteUnavailable: return "이 배틀에서는 중간투표를 사용할 수 없어요." + case .invalidMidVoteOption: return "이 선택 지점의 선택지가 아니에요." + case .midVoteLocked: return "사후투표를 마친 뒤에는 중간투표를 바꿀 수 없어요." + case .midVoteRequired: return "선택 지점의 투표를 완료해 주세요." case .voteNotFound: return "투표 내역을 찾을 수 없어요." case .preVoteRequired: return "먼저 사전투표를 완료해 주세요." case .alreadySubmitted: return "사후투표는 다시 제출할 수 없어요." diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift index 1fcd2ac1..752081ab 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift @@ -30,7 +30,9 @@ public enum ClassError: LocalizedError, Equatable, Sendable { case topicVersionNotFound case topicDraftUsed case inviteCodeFailed + case requestKeyConflict case network(String) + case userMessage(String) case unknown(String) public var errorDescription: String? { @@ -61,7 +63,10 @@ public enum ClassError: LocalizedError, Equatable, Sendable { case .topicVersionNotFound: return "존재하지 않는 주제 버전입니다." case .topicDraftUsed: return "이 주제 초안으로 이미 만든 클래스가 있습니다." case .inviteCodeFailed: return "초대코드 생성에 실패했습니다. 다시 시도해 주세요." - case let .network(message), let .unknown(message): return message + case .requestKeyConflict: return "클래스 생성 요청이 충돌했어요. 입력 내용을 확인하고 다시 시도해 주세요." + case let .network(message): return message + case let .userMessage(message): return message + case .unknown: return "클래스 요청을 처리하지 못했습니다. 다시 시도해 주세요." } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivity.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivity.swift new file mode 100644 index 00000000..7256b4e9 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivity.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 클래스 결과의 내 활동. 받은 추천 수와 내가 남긴 댓글을 커서 단위로 받는다. +public struct ClassMyActivity: Equatable, Sendable { + public let classID: Int + public let receivedLikeCount: Int + public let commentCount: Int + public let comments: [ClassMyActivityComment] + public let nextCursor: String? + public let hasNext: Bool + + public init( + classID: Int, + receivedLikeCount: Int, + commentCount: Int, + comments: [ClassMyActivityComment], + nextCursor: String?, + hasNext: Bool + ) { + self.classID = classID + self.receivedLikeCount = receivedLikeCount + self.commentCount = commentCount + self.comments = comments + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivityComment.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivityComment.swift new file mode 100644 index 00000000..ee4273df --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivityComment.swift @@ -0,0 +1,30 @@ +import Foundation + +/// 내가 다른 의견에 남긴 댓글 한 건. +public struct ClassMyActivityComment: Equatable, Sendable, Identifiable { + public let id: Int + public let perspectiveID: Int + public let perspectiveTitle: String + public let stance: String + public let content: String + public let likeCount: Int + public let createdAt: Date? + + public init( + id: Int, + perspectiveID: Int, + perspectiveTitle: String, + stance: String, + content: String, + likeCount: Int, + createdAt: Date? + ) { + self.id = id + self.perspectiveID = perspectiveID + self.perspectiveTitle = perspectiveTitle + self.stance = stance + self.content = content + self.likeCount = likeCount + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteCheckpoint.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteCheckpoint.swift new file mode 100644 index 00000000..9d0bbefe --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteCheckpoint.swift @@ -0,0 +1,15 @@ +public struct ClassResultMidVoteCheckpoint: Equatable, Identifiable, Sendable { + public let id: Int + public let voteCount: Int + public let options: [ClassResultMidVoteOption] + + public init( + id: Int, + voteCount: Int, + options: [ClassResultMidVoteOption] + ) { + self.id = id + self.voteCount = voteCount + self.options = options + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteOption.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteOption.swift new file mode 100644 index 00000000..c569823b --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteOption.swift @@ -0,0 +1,18 @@ +public struct ClassResultMidVoteOption: Equatable, Identifiable, Sendable { + public let id: Int + public let label: String + public let voteCount: Int + public let ratio: Double + + public init( + id: Int, + label: String, + voteCount: Int, + ratio: Double + ) { + self.id = id + self.label = label + self.voteCount = voteCount + self.ratio = ratio + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift index c21bb17a..d6391f51 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift @@ -5,6 +5,9 @@ public struct ClassResultStats: Equatable, Sendable { public let commentWriterCount: Int public let commentRate: Double public let postVoteCount: Int + public let midVoteEnabled: Bool + public let midVoteCount: Int + public let midVoteCheckpoints: [ClassResultMidVoteCheckpoint] public let options: [ClassResultOption] public init( @@ -14,6 +17,9 @@ public struct ClassResultStats: Equatable, Sendable { commentWriterCount: Int, commentRate: Double, postVoteCount: Int, + midVoteEnabled: Bool = false, + midVoteCount: Int = 0, + midVoteCheckpoints: [ClassResultMidVoteCheckpoint] = [], options: [ClassResultOption] ) { self.activeMemberCount = activeMemberCount @@ -22,6 +28,9 @@ public struct ClassResultStats: Equatable, Sendable { self.commentWriterCount = commentWriterCount self.commentRate = commentRate self.postVoteCount = postVoteCount + self.midVoteEnabled = midVoteEnabled + self.midVoteCount = midVoteCount + self.midVoteCheckpoints = midVoteCheckpoints self.options = options } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift index c026d7e0..4932f71e 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift @@ -16,6 +16,7 @@ public struct ClassCreation: Equatable, Sendable { public var requiresComment: Bool public var ownerParticipates: Bool public var ownerDisplayName: String? + public var requestKey: String? public init( name: String, @@ -24,7 +25,8 @@ public struct ClassCreation: Equatable, Sendable { allowsAnonymousOpinion: Bool, requiresComment: Bool, ownerParticipates: Bool = false, - ownerDisplayName: String? = nil + ownerDisplayName: String? = nil, + requestKey: String? = nil ) { self.name = name self.deadline = deadline @@ -34,6 +36,7 @@ public struct ClassCreation: Equatable, Sendable { self.requiresComment = requiresComment self.ownerParticipates = ownerParticipates self.ownerDisplayName = ownerDisplayName + self.requestKey = requestKey } public init( @@ -43,7 +46,8 @@ public struct ClassCreation: Equatable, Sendable { allowsAnonymousOpinion: Bool, requiresComment: Bool, ownerParticipates: Bool = false, - ownerDisplayName: String? = nil + ownerDisplayName: String? = nil, + requestKey: String? = nil ) { self.name = name self.deadline = deadline @@ -53,5 +57,6 @@ public struct ClassCreation: Equatable, Sendable { self.requiresComment = requiresComment self.ownerParticipates = ownerParticipates self.ownerDisplayName = ownerDisplayName + self.requestKey = requestKey } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassMidVoteChoice.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassMidVoteChoice.swift new file mode 100644 index 00000000..18dba431 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassMidVoteChoice.swift @@ -0,0 +1,15 @@ +public struct ClassMidVoteChoice: Equatable, Sendable { + public let checkpointID: Int + public let optionID: Int + public let label: String + + public init( + checkpointID: Int, + optionID: Int, + label: String + ) { + self.checkpointID = checkpointID + self.optionID = optionID + self.label = label + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift index 9130be62..aed8ca44 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift @@ -1,6 +1,7 @@ public struct ClassVoteHistory: Equatable, Sendable { public let battleTitle: String public let preVote: ClassVoteChoice + public let midVotes: [ClassMidVoteChoice] public let postVote: ClassVoteChoice? public let status: ClassBattleStep public let opinionChanged: Bool? @@ -8,12 +9,14 @@ public struct ClassVoteHistory: Equatable, Sendable { public init( battleTitle: String, preVote: ClassVoteChoice, + midVotes: [ClassMidVoteChoice] = [], postVote: ClassVoteChoice?, status: ClassBattleStep, opinionChanged: Bool? ) { self.battleTitle = battleTitle self.preVote = preVote + self.midVotes = midVotes self.postVote = postVote self.status = status self.opinionChanged = opinionChanged diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift index c78b435e..88e927eb 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift @@ -11,6 +11,7 @@ import Foundation public protocol ClassBattleInterface: Sendable { func fetchBattle(classID: Int) async throws -> ClassBattleSession func submitPreVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt + func submitMidVote(classID: Int, checkpointID: Int, optionID: Int) async throws -> ClassVoteReceipt func completeTTS(classID: Int) async throws func submitPostVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt func fetchMyVotes(classID: Int) async throws -> ClassVoteHistory diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift index ee0404ce..31f59977 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift @@ -9,6 +9,14 @@ public extension MockClassRepository { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitPreVote") } + public func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitMidVote") + } + public func completeTTS(classID: Int) async throws { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: completeTTS") } @@ -109,6 +117,13 @@ public extension MockClassRepository { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchInsight") } + public func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchMyActivity") + } + public func retryInsight(roomID: Int) async throws { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: retryInsight") } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift index dac4b067..4719fb1a 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift @@ -13,6 +13,10 @@ public protocol ClassResultInterface: Sendable { func fetchPersonalResult(roomID: Int) async throws -> ClassAIPersonalFeedback func fetchInsight(roomID: Int) async throws -> ClassAIInsight func retryInsight(roomID: Int) async throws + func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity } public enum ClassResultRepositoryDependency: TestDependencyKey { diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift index 748b4666..50fad1ad 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift @@ -23,7 +23,7 @@ public protocol ClassRoomInterface: Sendable { public extension ClassRoomInterface { func updateName(id _: Int, name _: String) async throws -> ClassRoom { - throw ClassError.unknown("클래스 이름 변경을 지원하지 않습니다.") + throw ClassError.userMessage("클래스 이름 변경을 지원하지 않습니다.") } func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift index 40bce229..d21d0003 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift @@ -37,6 +37,7 @@ struct ClassPersonalReportDTO: Decodable, Sendable { let state: ClassAIGenerationState let feedback: Feedback? let preOption: VoteOption? + let midVotes: [ClassMidVoteDTO]? let postOption: VoteOption? let perspectiveCount: Int? let commentCount: Int? diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift index c6ed6220..92d5d8ab 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift @@ -39,5 +39,6 @@ struct ClassBattleSessionDTO: Decodable, Sendable { let commentRequired: Bool let canParticipate: Bool let participationCompleted: Bool + let midVoteEnabled: Bool? let battle: Battle } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMyActivityDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMyActivityDTO.swift new file mode 100644 index 00000000..0e8544e5 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMyActivityDTO.swift @@ -0,0 +1,20 @@ +import Foundation + +struct ClassMyActivityDTO: Decodable, Sendable { + struct Comment: Decodable, Sendable { + let commentId: Int? + let perspectiveId: Int? + let perspectiveTitle: String? + let stance: String? + let content: String? + let likeCount: Int? + let createdAt: String? + } + + let classId: Int? + let receivedLikeCount: Int? + let commentCount: Int? + let comments: [Comment]? + let nextCursor: String? + let hasNext: Bool? +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift index 1bb7d9b9..7d0d77f9 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift @@ -10,7 +10,7 @@ struct ClassPerspectiveReceiptDTO: Decodable, Sendable { struct ClassPerspectiveItemDTO: Decodable, Sendable { struct User: Decodable, Sendable { - let userTag: String + let userTag: String? let nickname: String let characterType: String? let characterImageUrl: String? diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift index e311e01a..fe3fd56d 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift @@ -4,6 +4,19 @@ import PickeCoreUtility struct ClassResultDTO: Decodable, Sendable { struct Stats: Decodable, Sendable { + struct MidVoteCheckpoint: Decodable, Sendable { + struct Option: Decodable, Sendable { + let optionId: Int + let label: String + let voteCount: Int + let ratio: Double + } + + let checkpointId: Int + let voteCount: Int + let options: [Option] + } + struct Option: Decodable, Sendable { let optionId: Int let title: String @@ -19,6 +32,9 @@ struct ClassResultDTO: Decodable, Sendable { let commentWriterCount: Int let commentRate: Double let postVoteCount: Int + let midVoteEnabled: Bool? + let midVoteCount: Int? + let midVoteCheckpoints: [MidVoteCheckpoint]? let options: [Option] } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift index 5f209386..ba24085e 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift @@ -7,6 +7,12 @@ struct ClassVoteReceiptDTO: Decodable, Sendable { let status: String } +struct ClassMidVoteDTO: Decodable, Sendable { + let checkpointId: Int + let optionId: Int + let label: String +} + struct ClassVoteHistoryDTO: Decodable, Sendable { struct Choice: Decodable, Sendable { let optionId: Int @@ -17,6 +23,7 @@ struct ClassVoteHistoryDTO: Decodable, Sendable { let battleTitle: String let preVote: Choice + let midVotes: [ClassMidVoteDTO]? let postVote: Choice? let status: String let opinionChanged: Bool? diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift index 51494e86..120b44c6 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift @@ -21,6 +21,7 @@ extension ClassPersonalReportDTO.Feedback.Evidence { extension ClassPersonalReportDTO.Feedback { func toDomain( preOption: ClassPersonalReportDTO.VoteOption?, + midVotes: [ClassMidVoteDTO], postOption: ClassPersonalReportDTO.VoteOption?, perspectiveCount: Int, commentCount: Int @@ -38,6 +39,7 @@ extension ClassPersonalReportDTO.Feedback { coverage: coverage, excludedCount: excludedCount ?? 0, preOption: preOption?.toDomain(), + midVotes: midVotes.map { $0.toDomain() }, postOption: postOption?.toDomain(), perspectiveCount: perspectiveCount, commentCount: commentCount @@ -52,6 +54,7 @@ extension ClassPersonalReportDTO { guard let feedback else { throw ClassAIFeedbackError.generationFailed } return feedback.toDomain( preOption: preOption, + midVotes: midVotes ?? [], postOption: postOption, perspectiveCount: perspectiveCount ?? 0, commentCount: commentCount ?? 0 diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift index da1592e7..fb9ba900 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift @@ -55,7 +55,8 @@ extension ClassBattleSessionDTO { commentRequired: commentRequired, canParticipate: canParticipate, participationCompleted: participationCompleted, - battle: try battle.toDomain() + battle: try battle.toDomain(), + midVoteEnabled: midVoteEnabled ?? false ) } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift index bf06f21d..f8168db2 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift @@ -15,7 +15,7 @@ extension ClassMemberDTO { extension ClassMemberDTO { static func toMemberList(_ payload: [Self]) throws -> ClassMemberList { guard let current = payload.first(where: \.me) else { - throw ClassError.unknown("내 멤버 정보를 찾을 수 없어요.") + throw ClassError.userMessage("내 멤버 정보를 찾을 수 없어요.") } return ClassMemberList( members: payload.map { $0.toDomain() }, diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMyActivityDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMyActivityDTO+.swift new file mode 100644 index 00000000..74b860a7 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMyActivityDTO+.swift @@ -0,0 +1,32 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassMyActivityDTO.Comment { + /// Android와 같이 댓글·의견 ID가 없는 항목은 버린다. + func toDomain() -> ClassMyActivityComment? { + guard let commentId, let perspectiveId else { return nil } + return ClassMyActivityComment( + id: commentId, + perspectiveID: perspectiveId, + perspectiveTitle: perspectiveTitle ?? "", + stance: stance ?? "", + content: content ?? "", + likeCount: likeCount ?? 0, + createdAt: createdAt.flatMap(ServerDateParser.parse) + ) + } +} + +extension ClassMyActivityDTO { + func toDomain(fallbackClassID: Int) -> ClassMyActivity { + ClassMyActivity( + classID: classId ?? fallbackClassID, + receivedLikeCount: receivedLikeCount ?? 0, + commentCount: commentCount ?? 0, + comments: comments?.compactMap { $0.toDomain() } ?? [], + nextCursor: nextCursor, + hasNext: hasNext ?? false + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift index e99be720..beaf61eb 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift @@ -14,7 +14,7 @@ extension ClassPerspectiveReceiptDTO { extension ClassPerspectiveItemDTO.User { func toDomain() -> ClassPerspectiveAuthor { ClassPerspectiveAuthor( - userTag: userTag, + userTag: userTag ?? "", nickname: nickname, characterType: characterType, characterImageURL: characterImageUrl.flatMap(URL.init(string:)) diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift index 6ed67760..b63d2679 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift @@ -14,6 +14,27 @@ extension ClassResultDTO.Stats.Option { } } +extension ClassResultDTO.Stats.MidVoteCheckpoint.Option { + func toDomain() -> ClassResultMidVoteOption { + ClassResultMidVoteOption( + id: optionId, + label: label, + voteCount: voteCount, + ratio: ratio + ) + } +} + +extension ClassResultDTO.Stats.MidVoteCheckpoint { + func toDomain() -> ClassResultMidVoteCheckpoint { + ClassResultMidVoteCheckpoint( + id: checkpointId, + voteCount: voteCount, + options: options.map { $0.toDomain() } + ) + } +} + extension ClassResultDTO.Stats { func toDomain() -> ClassResultStats { ClassResultStats( @@ -23,6 +44,9 @@ extension ClassResultDTO.Stats { commentWriterCount: commentWriterCount, commentRate: commentRate, postVoteCount: postVoteCount, + midVoteEnabled: midVoteEnabled ?? false, + midVoteCount: midVoteCount ?? 0, + midVoteCheckpoints: midVoteCheckpoints?.map { $0.toDomain() } ?? [], options: options.map { $0.toDomain() } ) } diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift index bec26639..253e1ec1 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift @@ -4,8 +4,9 @@ import PickeCoreUtility extension ClassTicketDTO { func toDomain() throws -> ClassTicket { + // Android `ClassTicketStatus.from`과 같이 모르는 상태값은 만료로 본다. + let status = ClassTicket.Status(rawValue: self.status) ?? .expired guard - let status = ClassTicket.Status(rawValue: status), let activatedAt = ServerDateParser.parse(activatedAt), let expiresAt = ServerDateParser.parse(expiresAt), let readGraceEndsAt = ServerDateParser.parse(readGraceEndsAt) diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift index 14d33d7a..5d077754 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift @@ -17,6 +17,16 @@ extension ClassVoteHistoryDTO.Choice { } } +extension ClassMidVoteDTO { + func toDomain() -> ClassMidVoteChoice { + ClassMidVoteChoice( + checkpointID: checkpointId, + optionID: optionId, + label: label + ) + } +} + extension ClassVoteHistoryDTO { func toDomain() throws -> ClassVoteHistory { guard let status = ClassBattleStep(rawValue: status) else { @@ -25,6 +35,7 @@ extension ClassVoteHistoryDTO { return ClassVoteHistory( battleTitle: battleTitle, preVote: preVote.toDomain(), + midVotes: midVotes?.map { $0.toDomain() } ?? [], postVote: postVote?.toDomain(), status: status, opinionChanged: opinionChanged diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift index 195a9429..92326fd0 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift @@ -31,6 +31,7 @@ enum ClassErrorMapper { case "CLASS_404": return .notFound case "CLASS_409_FULL": return .full case "CLASS_409_DELETED": return .deleted + case "CLASS_409_REQUEST_KEY": return .requestKeyConflict case "CLASS_409_OWNER": return .ownClass case "CLASS_403_REMOVED": return .removed case "CLASS_403_TICKET": return .ticketRequired diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift index cbd1ae18..91e012c7 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift @@ -40,6 +40,20 @@ extension ClassRepositoryImpl { } catch { throw Self.battleError(error) } } + public func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + do { + let payload = try await client.send( + ClassBattleService.midVote(classID: classID, checkpointID: checkpointID, optionID: optionID), + as: ClassVoteReceiptDTO.self + ) + return try payload.toDomain() + } catch { throw Self.battleError(error) } + } + public func submitPostVote( classID: Int, optionID: Int @@ -125,6 +139,11 @@ extension ClassRepositoryImpl { case "CLASS_409_PRE_VOTE_LOCKED": return .preVoteLocked case "BATTLE_OPT_404": return .invalidOption case "CLASS_409_TTS": return .ttsUnavailable + case "CLASS_409_TTS_INCOMPLETE": return .ttsIncomplete + case "CLASS_409_MID_VOTE": return .midVoteUnavailable + case "CLASS_404_MID_VOTE_OPTION": return .invalidMidVoteOption + case "CLASS_409_MID_VOTE_LOCKED": return .midVoteLocked + case "CLASS_409_MID_VOTE_REQUIRED": return .midVoteRequired case "VOTE_404": return .voteNotFound case "VOTE_409_PRE": return .preVoteRequired case "VOTE_409_SUB": return .alreadySubmitted diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift index 040b152f..50b1051c 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift @@ -13,7 +13,7 @@ import PickeNetwork extension ClassRepositoryImpl { public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { guard (1 ... 3).contains(filter.categories.count), filter.keyword.count <= 50 else { - throw ClassError.unknown("추천 조건을 확인해 주세요.") + throw ClassError.userMessage("추천 조건을 확인해 주세요.") } do { let data = try await client.send( diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift index cbf51ed7..aacd394b 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift @@ -52,6 +52,26 @@ extension ClassRepositoryImpl { } } + public func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity { + do { + let payload = try await client.send( + ClassResultService.myActivity(roomID, cursor: cursor), + as: ClassMyActivityDTO.self + ) + return payload.toDomain(fallbackClassID: roomID) + } catch let error as PickeNetworkError { + if case let .response(response) = error { + throw Self.resultError(code: response.code, message: response.message) + } + throw ClassError.from(error) + } catch { + throw ClassError.from(error) + } + } + static func resultError( code: String?, message: String? diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift index 9434363c..80b9c3cd 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift @@ -17,6 +17,14 @@ extension ClassUseCaseImpl { try await repository.submitPreVote(classID: classID, optionID: optionID) } + public func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + try await repository.submitMidVote(classID: classID, checkpointID: checkpointID, optionID: optionID) + } + public func completeTTS(classID: Int) async throws { try await repository.completeTTS(classID: classID) } diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift index 6b6d188b..cdbf3fb9 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift @@ -24,4 +24,11 @@ extension ClassUseCaseImpl { public func retryInsight(roomID: Int) async throws { try await repository.retryInsight(roomID: roomID) } + + public func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity { + try await repository.fetchMyActivity(roomID: roomID, cursor: cursor) + } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift index 16779be3..b0e1eb9a 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift @@ -10,6 +10,7 @@ struct ClassBattleAPIContractTests { func 클래스_배틀_경로와_투표_의견_요청_본문이_명세와_일치한다() throws { let detail = ClassBattleService.detail(classID: 5) let preVote = ClassBattleService.preVote(classID: 5, optionID: 31) + let midVote = ClassBattleService.midVote(classID: 5, checkpointID: 801, optionID: 9001) let postVote = ClassBattleService.postVote(classID: 5, optionID: 32) let publish = ClassBattleService.publishPerspective(classID: 5, content: "의견") let perspectives = ClassBattleService.perspectives( @@ -24,6 +25,8 @@ struct ClassBattleAPIContractTests { #expect(detail.method == .get) #expect(try preVote.url().path == "/api/v1/classes/5/battle/votes/pre") #expect(preVote.method == .post) + #expect(try midVote.url().path == "/api/v1/classes/5/battle/votes/mid") + #expect(midVote.method == .post) #expect(try postVote.url().path == "/api/v1/classes/5/battle/votes/post") #expect(postVote.method == .post) #expect(try ClassBattleService.ttsComplete(classID: 5).url().path == "/api/v1/classes/5/battle/votes/tts-complete") @@ -35,6 +38,7 @@ struct ClassBattleAPIContractTests { #expect(try ClassBattleService.myPerspectives(classID: 5).url().path == "/api/v1/classes/5/battle/perspectives/me") guard let preVoteParameters = preVote.parameters, + let midVoteParameters = midVote.parameters, let postVoteParameters = postVote.parameters, let publishParameters = publish.parameters, let perspectiveParameters = perspectives.parameters @@ -44,10 +48,12 @@ struct ClassBattleAPIContractTests { } let encoder = JSONEncoder() let preVoteJSON = try JSONSerialization.jsonObject(with: encoder.encode(preVoteParameters)) as? [String: Int] + let midVoteJSON = try JSONSerialization.jsonObject(with: encoder.encode(midVoteParameters)) as? [String: Int] let postVoteJSON = try JSONSerialization.jsonObject(with: encoder.encode(postVoteParameters)) as? [String: Int] let publishJSON = try JSONSerialization.jsonObject(with: encoder.encode(publishParameters)) as? [String: String] let perspectiveJSON = try JSONSerialization.jsonObject(with: encoder.encode(perspectiveParameters)) as? [String: Any] #expect(preVoteJSON == ["optionId": 31]) + #expect(midVoteJSON == ["checkpointId": 801, "optionId": 9001]) #expect(postVoteJSON == ["optionId": 32]) #expect(publishJSON == ["content": "의견"]) #expect(perspectiveJSON?["cursor"] as? String == "next") @@ -86,7 +92,7 @@ struct ClassBattleAPIContractTests { func 클래스_배틀_응답은_클래스_권한과_배틀_진행을_함께_복원한다() throws { let json = """ {"classId":5,"myRole":"MEMBER","closeAt":"2026-12-31T23:59:59","closed":false, - "commentRequired":true,"canParticipate":true,"participationCompleted":false, + "commentRequired":true,"canParticipate":true,"participationCompleted":false,"midVoteEnabled":true, "battle":{"battleInfo":{"battleId":9,"title":"AI가 쓴 글도 창작일까?", "options":[{"optionId":31,"label":"A","title":"창작이다"}]}, "userVoteStatus":"PRE_VOTE","currentStep":"PRE_VOTE"}} @@ -96,6 +102,7 @@ struct ClassBattleAPIContractTests { #expect(session.classID == 5) #expect(session.myRole == .member) #expect(session.canParticipate) + #expect(session.midVoteEnabled) #expect(session.battle.currentStep == .preVote) #expect(session.battle.options.first?.id == 31) } @@ -107,7 +114,7 @@ struct ClassBattleAPIContractTests { "postVote":{"optionId":32,"label":"B","title":"창작이 아니다"},"status":"COMPLETED","opinionChanged":true} """ let pageJSON = """ - {"items":[{"perspectiveId":50,"user":{"userTag":"u1","nickname":"피케"}, + {"items":[{"perspectiveId":50,"user":{"userTag":null,"nickname":"피케"}, "option":{"optionId":31,"label":"A","title":"창작이다"},"content":"의견", "likeCount":3,"commentCount":1,"isLiked":false,"isMyPerspective":true, "createdAt":"2026-10-21T12:00:00"}],"nextCursor":"next","hasNext":true} @@ -118,6 +125,7 @@ struct ClassBattleAPIContractTests { #expect(history.status == .completed) #expect(history.opinionChanged == true) #expect(page.items.first?.id == 50) + #expect(page.items.first?.author.userTag == "") #expect(page.items.first?.isMyPerspective == true) #expect(page.nextCursor == "next") #expect(page.hasNext) diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift index 1502babc..f2609eae 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift @@ -13,6 +13,7 @@ struct ClassErrorMappingTests { ("CLASS_404", .notFound), ("CLASS_409_FULL", .full), ("CLASS_409_DELETED", .deleted), + ("CLASS_409_REQUEST_KEY", .requestKeyConflict), ("CLASS_409_OWNER", .ownClass), ("CLASS_403_REMOVED", .removed), ("CLASS_403_TICKET", .ticketRequired), @@ -43,4 +44,18 @@ struct ClassErrorMappingTests { func 메시지가_없으면_기본_문구를_보여준다() { #expect(ClassErrorMapper.map(code: nil, message: nil, fallback: "기본 문구") == .network("기본 문구")) } + + @Test + func 알수없는_내부_오류는_구현_상세를_화면에_표시하지_않는다() { + let error = ClassError.unknown("AuthenticationError.excessiveRefresh") + #expect(error.message == "클래스 요청을 처리하지 못했습니다. 다시 시도해 주세요.") + } + + @Test + func 알수없는_오류여도_명시적인_사용자_안내는_유지한다() { + let message = "AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요." + #expect(ClassError.userMessage(message).message == message) + #expect(ClassError.unknown("AuthenticationError.excessiveRefresh: 인증 토큰 갱신 실패").message != + "AuthenticationError.excessiveRefresh: 인증 토큰 갱신 실패") + } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift index 87d61e13..a2d9a270 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift @@ -49,7 +49,7 @@ struct ClassMemberAPITests { [{"memberId":10,"displayName":"운영자","role":"OWNER","characterUrl":null,"me":false}] """ let payload = try JSONDecoder().decode([ClassMemberDTO].self, from: Data(json.utf8)) - #expect(throws: ClassError.unknown("내 멤버 정보를 찾을 수 없어요.")) { + #expect(throws: ClassError.userMessage("내 멤버 정보를 찾을 수 없어요.")) { try ClassMemberDTO.toMemberList(payload) } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift index b9b4a243..f93bbff8 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift @@ -102,7 +102,8 @@ struct ClassRecommendationTests { battleId: 12, allowsAnonymousOpinion: false, requiresComment: true, - ownerParticipates: false + ownerParticipates: false, + requestKey: "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b" ) let request = ClassroomService.create(creation) guard let body = request.parameters else { @@ -120,6 +121,7 @@ struct ClassRecommendationTests { #expect(fields["deadline"] as? String == "2026-12-31T23:59:59") #expect((fields["commentRequired"] as? Bool) == true) #expect((fields["ownerParticipates"] as? Bool) == false) + #expect(fields["requestKey"] as? String == "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b") #expect(fields["allowsAnonymousOpinion"] == nil) } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift index 53e31e9c..f32d4345 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift @@ -44,6 +44,11 @@ struct ClassResultAPIContractTests { "stats": { "activeMemberCount": 12, "completedCount": 10, "participationRate": 83.3, "commentWriterCount": 9, "commentRate": 75.0, "postVoteCount": 11, + "midVoteEnabled": true, "midVoteCount": 10, + "midVoteCheckpoints": [{ + "checkpointId": 801, "voteCount": 10, + "options": [{"optionId": 9002, "label": "무죄", "voteCount": 6, "ratio": 60.0}] + }], "options": [ { "optionId": 31, "title": "창작이다", "preVoteCount": 8, "postVoteCount": 6, "postVoteRatio": 54.5 } @@ -59,6 +64,11 @@ struct ClassResultAPIContractTests { #expect(result.stats.completedCount == 10) #expect(result.stats.participationRate == 83.3) #expect(result.stats.options.first?.id == 31) + #expect(result.stats.midVoteEnabled) + #expect(result.stats.midVoteCount == 10) + #expect(result.stats.midVoteCheckpoints.first?.id == 801) + #expect(result.stats.midVoteCheckpoints.first?.options.first?.id == 9002) + #expect(result.stats.midVoteCheckpoints.first?.options.first?.ratio == 60.0) #expect(result.myReportState == nil) #expect(result.insightState == "SUCCEEDED") } @@ -84,6 +94,7 @@ struct ClassResultAPIContractTests { { "state": "SUCCEEDED", "preOption": { "optionId": 31, "title": "창작이다", "stance": "처음 의견" }, + "midVotes": [{"checkpointId":801,"optionId":9002,"label":"무죄"}], "postOption": { "optionId": 32, "title": "창작이 아니다", "stance": "바뀐 의견" }, "perspectiveCount": 1, "commentCount": 2, @@ -106,11 +117,35 @@ struct ClassResultAPIContractTests { #expect(feedback.evidence.first?.content == "삭제되지 않은 원문") #expect(feedback.evidence.first?.quote == "짧은 인용") #expect(feedback.preOption?.id == 31) + #expect(feedback.midVotes == [ClassMidVoteChoice(checkpointID: 801, optionID: 9002, label: "무죄")]) #expect(feedback.postOption?.title == "창작이 아니다") #expect(feedback.perspectiveCount == 1) #expect(feedback.commentCount == 2) } + @Test + func 투표_이력의_중간_선택을_보존한다() throws { + let json = """ + {"battleTitle":"Picke(TTS) 배틀","preVote":{"optionId":31,"label":"A","title":"창작이다"}, + "midVotes":[{"checkpointId":801,"optionId":9002,"label":"무죄"}], + "postVote":null,"status":"PRE_VOTE","opinionChanged":null} + """ + + let history = try JSONDecoder().decode(ClassVoteHistoryDTO.self, from: Data(json.utf8)).toDomain() + #expect(history.midVotes == [ClassMidVoteChoice(checkpointID: 801, optionID: 9002, label: "무죄")]) + } + + @Test + func 중간_투표가_없는_이전_응답도_빈_선택으로_읽는다() throws { + let json = """ + {"battleTitle":"일반 배틀","preVote":{"optionId":31,"label":"A","title":"창작이다"}, + "postVote":null,"status":"PRE_VOTE","opinionChanged":null} + """ + + let history = try JSONDecoder().decode(ClassVoteHistoryDTO.self, from: Data(json.utf8)).toDomain() + #expect(history.midVotes.isEmpty) + } + @Test func 운영자_인사이트의_근거와_대표_의견을_보존한다() throws { let json = """ @@ -128,4 +163,64 @@ struct ClassResultAPIContractTests { #expect(insight.evidence.first?.sourceID == 50) #expect(insight.highlights.first?.content == "대표 의견") } + + @Test + func 내_활동_경로와_커서_쿼리가_Android와_일치한다() throws { + let first = ClassResultService.myActivity(5, cursor: nil) + let next = ClassResultService.myActivity(5, cursor: "c-2") + #expect(first.path == "classes/5/result/my-activity") + #expect(try first.url().path == "/api/v1/classes/5/result/my-activity") + #expect(first.method == .get) + + let encoder = JSONEncoder() + guard let firstParameters = first.parameters, let nextParameters = next.parameters else { + Issue.record("내 활동 요청 파라미터가 없습니다.") + return + } + let firstJSON = try JSONSerialization.jsonObject(with: encoder.encode(firstParameters)) as? [String: Any] + let nextJSON = try JSONSerialization.jsonObject(with: encoder.encode(nextParameters)) as? [String: Any] + #expect(firstJSON?.isEmpty == true) + #expect(nextJSON?["cursor"] as? String == "c-2") + } + + @Test + func 내_활동_응답을_매핑하고_ID_없는_댓글은_버린다() throws { + let json = """ + { + "classId": 5, "receivedLikeCount": 12, "commentCount": 3, + "comments": [ + { "commentId": 71, "perspectiveId": 9, "perspectiveTitle": "처벌을 강화해야 한다", + "stance": "찬성", "content": "동의해요", "likeCount": 2, + "createdAt": "2026-11-02T10:00:00.000Z" }, + { "commentId": null, "perspectiveId": 9, "content": "버려질 댓글" }, + { "commentId": 72, "perspectiveId": 10 } + ], + "nextCursor": "c-2", "hasNext": true + } + """ + let activity = try JSONDecoder().decode(ClassMyActivityDTO.self, from: Data(json.utf8)) + .toDomain(fallbackClassID: 99) + #expect(activity.classID == 5) + #expect(activity.receivedLikeCount == 12) + #expect(activity.commentCount == 3) + #expect(activity.comments.map(\.id) == [71, 72]) + #expect(activity.comments.first?.perspectiveTitle == "처벌을 강화해야 한다") + #expect(activity.comments.first?.createdAt == ServerDateParser.parse("2026-11-02T10:00:00.000Z")) + #expect(activity.comments.last?.perspectiveTitle == "") + #expect(activity.comments.last?.likeCount == 0) + #expect(activity.comments.last?.createdAt == nil) + #expect(activity.nextCursor == "c-2") + #expect(activity.hasNext) + + let empty = try JSONDecoder().decode(ClassMyActivityDTO.self, from: Data("{}".utf8)) + .toDomain(fallbackClassID: 99) + #expect(empty == ClassMyActivity( + classID: 99, + receivedLikeCount: 0, + commentCount: 0, + comments: [], + nextCursor: nil, + hasNext: false + )) + } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift index b466520e..6fd732c3 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift @@ -72,12 +72,12 @@ struct ClassTicketAPITests { } @Test - func 알_수_없는_이용권_상태는_잘못된_응답으로_처리한다() throws { + func 알_수_없는_이용권_상태는_만료로_처리한다() throws { let json = """ {"ticketId":3,"capacity":15,"status":"REVOKED","activatedAt":"2026-10-01T10:00:00",\ "expiresAt":"2027-04-01T10:00:00","remainingDays":0,"readGraceEndsAt":"2027-05-01T10:00:00"} """ let payload = try JSONDecoder().decode(ClassTicketDTO.self, from: Data(json.utf8)) - #expect(throws: ClassTicketError.invalidResponse) { try payload.toDomain() } + #expect(try payload.toDomain().status == .expired) } } diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift index c0d88d85..de2b8b4d 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift @@ -13,6 +13,7 @@ public struct HomeBundle: Equatable { public let heroes: [HeroBattle] public let hotBattles: [HotBattle] public let bestBattles: [BestBattle] + public let bestComments: [HomeBestComment] public let quizzes: [QuizQuestion] public let votes: [VoteQuestion] public let newBattles: [NewBattle] @@ -22,6 +23,7 @@ public struct HomeBundle: Equatable { heroes: [HeroBattle], hotBattles: [HotBattle], bestBattles: [BestBattle], + bestComments: [HomeBestComment] = [], quizzes: [QuizQuestion], votes: [VoteQuestion], newBattles: [NewBattle] @@ -30,6 +32,7 @@ public struct HomeBundle: Equatable { self.heroes = heroes self.hotBattles = hotBattles self.bestBattles = bestBattles + self.bestComments = bestComments self.quizzes = quizzes self.votes = votes self.newBattles = newBattles diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift index d71f39ee..a7fb6cfc 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift @@ -9,6 +9,8 @@ import Foundation public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { case common = "COMMON" + case pathA = "PATH_A" + case pathB = "PATH_B" case unknown public init(rawValue: String) { diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift index 73182d79..25f8aa2b 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift @@ -8,16 +8,22 @@ import Foundation public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { + public let optionID: Int? public let label: String + public let title: String? public let nextNodeId: Int public var id: String { "\(label)-\(nextNodeId)" } public init( label: String, - nextNodeId: Int + nextNodeId: Int, + optionID: Int? = nil, + title: String? = nil ) { + self.optionID = optionID self.label = label + self.title = title self.nextNodeId = nextNodeId } } diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Section/Comment/HomeBestComment.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Comment/HomeBestComment.swift new file mode 100644 index 00000000..918c55cc --- /dev/null +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Comment/HomeBestComment.swift @@ -0,0 +1,30 @@ +// +// HomeBestComment.swift +// HomeDomainInterface +// + +import Foundation + +/// 홈 API가 제공하는 Best 댓글. 없는 battleId는 DTO 매핑에서 제외한다. +public struct HomeBestComment: Equatable, Identifiable { + public let battleId: Int + public let battleTitle: String + public let optionTitle: String + public let content: String + + public var id: Int { + battleId + } + + public init( + battleId: Int, + battleTitle: String, + optionTitle: String, + content: String + ) { + self.battleId = battleId + self.battleTitle = battleTitle + self.optionTitle = optionTitle + self.content = content + } +} diff --git a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift index 0ab6a8b0..62c1e900 100644 --- a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift +++ b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift @@ -63,6 +63,18 @@ public extension BestBattleDTO { } } +public extension BestCommentDTO { + func toDomain() -> HomeBestComment? { + guard let battleId else { return nil } + return HomeBestComment( + battleId: battleId, + battleTitle: battleTitle ?? "", + optionTitle: optionTitle ?? "", + content: content ?? "" + ) + } +} + public extension TodayQuizDTO { func toDomain() -> QuizQuestion { QuizQuestion( @@ -127,6 +139,7 @@ public extension HomeDataDTO { bestBattles: bestBattles.enumerated().map { idx, dto in dto.toDomain(rank: idx + 1) }, + bestComments: bestComments?.compactMap { $0.toDomain() } ?? [], quizzes: todayQuizzes.map { $0.toDomain() }, votes: todayVotes.map { $0.toDomain() }, newBattles: newBattles.map { $0.toDomain() } diff --git a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift index ffafba67..3ff4891b 100644 --- a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift +++ b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift @@ -15,11 +15,20 @@ public struct HomeDataDTO: Decodable { public let editorPicks: [EditorPickDTO] public let trendingBattles: [TrendingBattleDTO] public let bestBattles: [BestBattleDTO] + public let bestComments: [BestCommentDTO]? public let todayQuizzes: [TodayQuizDTO] public let todayVotes: [TodayVoteDTO] public let newBattles: [NewBattleDTO] } +/// Android 홈 계약의 선택 필드. 현재 서버가 아직 보내지 않는 경우에도 홈 로드는 유지한다. +public struct BestCommentDTO: Decodable { + public let battleId: Int? + public let battleTitle: String? + public let optionTitle: String? + public let content: String? +} + public struct TagDTO: Decodable, Equatable { public let tagId: Int public let name: String diff --git a/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift index 8ec83931..d65d7d6f 100644 --- a/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift +++ b/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift @@ -9,6 +9,11 @@ import HomeDomainInterface import Testing struct BattleScenarioTimelineTests { + @Test + func 추천_오디오_경로의_분기_키를_인식한다() { + #expect(RecommendedPathKey(rawValue: "PATH_A") == .pathA) + #expect(RecommendedPathKey(rawValue: "PATH_B") == .pathB) + } @Test func linearContentReachesClosingWithoutExplicitLink() { let scenario = makeScenario() diff --git a/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift index b826a746..8cde60e8 100644 --- a/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift +++ b/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift @@ -70,6 +70,10 @@ struct HomeRepositoryTests { "viewCount": 900 } ], + "bestComments": [ + { "battleId": 21, "battleTitle": "인간은 본래 선한가", "optionTitle": "선하다", "content": "선하게 태어나요" }, + { "battleId": null, "battleTitle": "제외", "optionTitle": "", "content": "" } + ], "todayQuizzes": [ { "battleId": 31, @@ -175,6 +179,12 @@ struct HomeRepositoryTests { #expect(bundle.bestBattles[1].rank == 2) #expect(bundle.bestBattles[1].battleId == 22) #expect(bundle.bestBattles[1].tags.isEmpty) + #expect(bundle.bestComments == [HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + )]) #expect(bundle.quizzes.count == 1) #expect(bundle.quizzes[0].isCorrectA == true) @@ -205,6 +215,7 @@ struct HomeRepositoryTests { #expect(bundle.heroes.isEmpty) #expect(bundle.hotBattles.isEmpty) #expect(bundle.bestBattles.isEmpty) + #expect(bundle.bestComments.isEmpty) #expect(bundle.quizzes.isEmpty) #expect(bundle.votes.isEmpty) #expect(bundle.newBattles.isEmpty) diff --git a/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift index b4e612ae..21820860 100644 --- a/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift +++ b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift @@ -15,6 +15,8 @@ public enum PerspectiveError: LocalizedError, Equatable { case backendError(String) case serverError(Int) case unknown(String) + /// 서버 `REPORT_409`: 이미 신고한 의견. + case alreadyReported public var errorDescription: String? { switch self { @@ -25,6 +27,7 @@ public enum PerspectiveError: LocalizedError, Equatable { case let .backendError(message): return "perspective 처리 실패: \(message)" case let .serverError(code): return "서버 오류 (코드: \(code))" case let .unknown(message): return "알 수 없는 오류: \(message)" + case .alreadyReported: return "이미 신고한 사용자입니다." } } } diff --git a/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift b/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift index 9e0d5d72..91ca1171 100644 --- a/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift +++ b/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift @@ -120,10 +120,18 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S } public func reportPerspective(perspectiveId: Int) async throws { - _ = try await client.send( - PerspectiveService.reportPerspective(perspectiveId: perspectiveId), - as: PickeEmptyResponse.self - ) + do { + _ = try await client.send( + PerspectiveService.reportPerspective(perspectiveId: perspectiveId), + as: PickeEmptyResponse.self + ) + } catch let error as PickeNetworkError { + // Android ReportPerspectiveUseCase와 같이 중복 신고는 서버 오류 코드로 구분한다. + if case let .response(response) = error, response.code == "REPORT_409" { + throw PerspectiveError.alreadyReported + } + throw error + } } public func reportComment(perspectiveId: Int, commentId: Int) async throws { diff --git a/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift index 6df12f23..feb7a7ac 100644 --- a/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift +++ b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift @@ -104,6 +104,52 @@ struct PerspectiveRepositoryTests { } } + // MARK: - reportPerspective + + @Test + func reportPerspective_duplicateCode_throwsAlreadyReported() async throws { + let json = """ + { + "statusCode": 409, + "data": null, + "error": { "code": "REPORT_409", "message": "이미 신고한 관점입니다" } + } + """ + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } + + await #expect(throws: PerspectiveError.alreadyReported) { + try await repo.reportPerspective(perspectiveId: 42) + } + } + + @Test + func reportPerspective_otherErrorCode_keepsNetworkResponseError() async throws { + let json = """ + { + "statusCode": 404, + "data": null, + "error": { "code": "PERSPECTIVE_404", "message": "존재하지 않는 관점입니다" } + } + """ + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } + + await expectNetworkResponseError( + statusCode: 404, + code: "PERSPECTIVE_404", + message: "존재하지 않는 관점입니다" + ) { + try await repo.reportPerspective(perspectiveId: 42) + } + } + // MARK: - fetchLabeledComments @Test diff --git a/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index 06c94537..542d16a7 100644 --- a/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -147,6 +147,7 @@ public struct CommentReplyFeature { case createResponse(Result) case updateResponse(Result, commentId: Int) case deleteResponse(Result) + case parentDeleteResponse(Result) case parentLikeResponse(Result) case replyLikeResponse(Result) case parentLikesResponse(Result) @@ -491,9 +492,14 @@ extension CommentReplyFeature { case .deleteParent: let perspectiveId = state.perspectiveId return .run { [repository = perspectiveUseCase] send in - try? await repository.deletePerspective(perspectiveId: perspectiveId) - await send(.delegate(.dismiss)) + let result = await Result { + try await repository.deletePerspective(perspectiveId: perspectiveId) + return perspectiveId + } + .mapError(PerspectiveError.from) + await send(.inner(.parentDeleteResponse(result))) } + .cancellable(id: CancelID.mutate, cancelInFlight: false) case .reportParent: let perspectiveId = state.perspectiveId @@ -566,6 +572,19 @@ extension CommentReplyFeature { } return .none + case let .parentDeleteResponse(result): + switch result { + case .success: + return .send(.delegate(.dismiss)) + case let .failure(error): + PickeLogger.error("[CommentReplyFeature] deleteParent failed: \(error.localizedDescription)", category: .ui) + state.customAlert = .alert( + title: "의견을 삭제할 수 없어요", + message: "클래스 설정이나 진행 상태를 확인해 주세요." + ) + return .none + } + case let .parentLikeResponse(result): switch result { case let .success(payload): diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift index 28a35abc..a7a1ad34 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift @@ -28,7 +28,10 @@ public struct ClassBattleTranscriptLine: Equatable, Identifiable, Sendable { } public extension ClassBattleTranscriptLine { - static func from(scenario: BattleScenario) -> [ClassBattleTranscriptLine] { + static func from( + scenario: BattleScenario, + selectedPaths: [Int: Int] = [:] + ) -> [ClassBattleTranscriptLine] { var transcript: [ClassBattleTranscriptLine] = [] var visibleNodeID = scenario.startNodeId var visitedNodeIDs = Set() @@ -81,7 +84,15 @@ public extension ClassBattleTranscriptLine { )) } } - guard let nextNodeID = scenario.nextNodeId(for: node) else { break } + let nextNodeID: Int? + if !node.interactiveOptions.isEmpty { + nextNodeID = node.interactiveOptions.first { + $0.nextNodeId == selectedPaths[node.nodeId] + }?.nextNodeId + } else { + nextNodeID = scenario.nextNodeId(for: node) + } + guard let nextNodeID else { break } visibleNodeID = nextNodeID } return transcript.sorted { diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveMenuKind.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveMenuKind.swift new file mode 100644 index 00000000..fb0c6928 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveMenuKind.swift @@ -0,0 +1,38 @@ +// +// ClassPerspectiveMenuKind.swift +// Class +// +// Created by Picke on 11/3/26. +// + +import ClassDomainInterface + +/// 클래스 의견 카드 더보기(•••) 메뉴 종류. Android `PerspectiveItemCard.perspectiveMenuItems`와 같다. +public enum ClassPerspectiveMenuKind: Equatable, Sendable { + case hidden + case report + case editOnly + case editAndDelete + + /// 목록 행: 서버가 남의 의견(false)이라고 알려 준 경우만 신고, 내 의견(true)은 상태 규칙, 모르면(nil) 메뉴 없음. + public init(listItem: ClassPerspectiveItem) { + switch listItem.isMyPerspective { + case .some(false): self = .report + case .some(true): self = .init(myStatus: listItem.status) + case .none: self = .hidden + } + } + + /// 내 의견: 검수 중은 메뉴 없음, 거절은 수정만, 그 외는 삭제·수정. + public init(myStatus status: String?) { + switch status?.uppercased() { + case "PENDING": self = .hidden + case "REJECTED": self = .editOnly + default: self = .editAndDelete + } + } + + public var hasMenu: Bool { + self != .hidden + } +} diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift index 29d9293d..1d6ece51 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift @@ -1,20 +1,36 @@ import ClassDomainInterface +import PickeCoreUtility public struct ClassPerspectiveRow: Equatable, Identifiable { public let id: Int public let perspective: ClassPerspectiveItem public let likeCountLabel: String public let commentCountLabel: String + public let menuKind: ClassPerspectiveMenuKind + + public var createdAtLabel: String { perspective.createdAt.relativeKoreanString } + + /// 공개되지 않은 내 의견의 상태 배지 문구(Android `comment_status_pending/rejected`). + public var statusBadgeTitle: String? { + if isPendingReview { return "검수중" } + if isRejected { return "거절됨" } + return nil + } + + public var isPendingReview: Bool { perspective.status?.uppercased() == "PENDING" } + public var isRejected: Bool { perspective.status?.uppercased() == "REJECTED" } public init( id: Int, perspective: ClassPerspectiveItem, likeCountLabel: String, - commentCountLabel: String + commentCountLabel: String, + menuKind: ClassPerspectiveMenuKind? = nil ) { self.id = id self.perspective = perspective self.likeCountLabel = likeCountLabel self.commentCountLabel = commentCountLabel + self.menuKind = menuKind ?? ClassPerspectiveMenuKind(listItem: perspective) } } diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift index 7d0ec44b..62f543fc 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift @@ -7,6 +7,7 @@ import Foundation import HomeDomainInterface import PickeCoreUtility import PerspectiveDomainInterface +import PickeSharedUI @Reducer public struct ClassBattleFlowFeature { @@ -43,6 +44,7 @@ public struct ClassBattleFlowFeature { public var hasMorePerspectives = false public var isLoadingPerspectives = false public var refreshAfterPerspectiveDetail = false + public var refreshAfterOwnPerspectiveDetail = false public var perspectiveErrorMessage: String? public var errorMessage: String? public var loadRequestID: UUID? @@ -52,6 +54,15 @@ public struct ClassBattleFlowFeature { public var voteStatsRequestID: UUID? public var perspectiveLikeRequestID: UUID? public var myPerspectiveRequestID: UUID? + /// 내 의견 API의 첫 항목. 공개되지 않았으면 목록 위 상태 카드로 보여 준다. + public var myPerspective: ClassPerspectiveItem? + public var menuTargetPerspectiveID: Int? + public var editingPerspectiveID: Int? + public var deleteTargetPerspectiveID: Int? + public var reportTargetPerspectiveID: Int? + public var mutationRequestID: UUID? + public var toastMessage: String? + @Presents public var customAlert: CustomAlertState? public var isAudioReady = false public var isPlaying = false public var hasListened = false @@ -59,6 +70,16 @@ public struct ClassBattleFlowFeature { public var audioDuration: TimeInterval = 0 public var playbackRate: Float = 1 public var transcript: [ClassBattleTranscriptLine] = [] + public var scenario: BattleScenario? + public var currentNodeID: Int? + public var pendingCheckpointID: Int? + public var selectedMidOptionID: Int? + public var selectedPaths: [Int: Int] = [:] + public var loadedAudioURL: URL? + public var midVoteRequestID: UUID? + public var pendingSeekTime: TimeInterval? + public var needsMidVoteRecovery = false + public var midVoteDisabledLocally = false public var selectedPerspectiveOptionID: Int? public var perspectiveSort: String = "popular" @@ -69,14 +90,23 @@ public struct ClassBattleFlowFeature { public var canParticipate: Bool { session?.canParticipate == true } public var canSubmit: Bool { + // 의견 수정은 이미 게시한 의견이 대상이라 게시 조건과 별도로 판단하고, 마감 여부는 서버에 맡긴다. + if editingPerspectiveID != nil { + return mutationRequestID == nil && submitRequestID == nil + && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } guard viewState == .loaded, canParticipate, submitRequestID == nil else { return false } guard let step else { return false } switch step { - case .none, .postVote: return selectedOptionID != nil + case .none: return selectedOptionID != nil + case .postVote: + return selectedOptionID != nil + && (!needsMidVoteRecovery || (hasListened && pendingCheckpointID == nil)) case .preVote: guard selectedOptionID != nil else { return false } - return selectedOptionID != voteHistory?.preVote.optionID || hasListened - case .ttsListening: return hasListened + return (!isPreVoteLocked && selectedOptionID != voteHistory?.preVote.optionID) || hasListened + case .ttsListening: + return hasListened && pendingCheckpointID == nil && hasAnsweredCurrentPath case .completed: return !hasSubmittedInCurrentSession && hasPublishedPerspective == false @@ -84,12 +114,18 @@ public struct ClassBattleFlowFeature { } } + public var isPreVoteLocked: Bool { + session?.midVoteEnabled == true + && (!(voteHistory?.midVotes.isEmpty ?? true) || !selectedPaths.isEmpty) + } + public var buttonTitle: String { guard let step else { return "불러오는 중" } switch step { case .none: return "사전 투표하기" case .preVote: - return selectedOptionID != voteHistory?.preVote.optionID ? "사전투표 변경하기" : "청취 완료" + return !isPreVoteLocked && selectedOptionID != voteHistory?.preVote.optionID + ? "사전투표 변경하기" : "청취 완료" case .ttsListening: return "청취 완료" case .postVote: return "최종 투표하기" case .completed: @@ -144,12 +180,42 @@ public struct ClassBattleFlowFeature { } public var shouldShowComposer: Bool { - step == .some(.completed) && canParticipate + if editingPerspectiveID != nil { return true } + return step == .some(.completed) && canParticipate && hasPublishedPerspective == false && !hasSubmittedInCurrentSession } + /// 공개되지 않은 내 의견(검수중·거절됨)이 목록에 없을 때 목록 맨 위에 보여 줄 행. + public var hiddenMyPerspectiveRow: ClassPerspectiveRow? { + guard let myPerspective, + ["PENDING", "REJECTED"].contains(myPerspective.status?.uppercased() ?? ""), + !perspectives.contains(where: { $0.id == myPerspective.id }) else { return nil } + return ClassPerspectiveRow( + id: -1, + perspective: myPerspective, + likeCountLabel: myPerspective.likeCount.decimalFormatted, + commentCountLabel: myPerspective.commentCount.decimalFormatted, + menuKind: ClassPerspectiveMenuKind(myStatus: myPerspective.status) + ) + } + + func perspectiveItem(id: Int) -> ClassPerspectiveItem? { + perspectives.first { $0.id == id } ?? (myPerspective?.id == id ? myPerspective : nil) + } + + func perspectiveMenuKind(id: Int) -> ClassPerspectiveMenuKind { + if let item = perspectives.first(where: { $0.id == id }) { + return ClassPerspectiveMenuKind(listItem: item) + } + if let myPerspective, myPerspective.id == id { + return ClassPerspectiveMenuKind(myStatus: myPerspective.status) + } + return .hidden + } + public var isAudioStep: Bool { step == .some(.preVote) || step == .some(.ttsListening) + || (step == .some(.postVote) && needsMidVoteRecovery) } public var audioProgress: Double { @@ -169,6 +235,38 @@ public struct ClassBattleFlowFeature { transcript.filter { $0.startTime <= currentTime } } + public var midVoteOptions: [ScenarioInteractiveOption] { + guard let pendingCheckpointID else { return [] } + return scenario?.nodes.first { $0.nodeId == pendingCheckpointID }?.interactiveOptions ?? [] + } + + public var currentNode: ScenarioNode? { + guard let scenario else { return nil } + return scenario.nodes.first { $0.nodeId == (currentNodeID ?? scenario.startNodeId) } + } + + public var canConfirmMidVote: Bool { + pendingCheckpointID != nil && selectedMidOptionID != nil && midVoteRequestID == nil + } + + public var hasAnsweredCurrentPath: Bool { + guard session?.midVoteEnabled == true, let scenario else { return true } + var nodeID: Int? = scenario.startNodeId + var visited = Set() + while let id = nodeID, visited.insert(id).inserted, + let node = scenario.nodes.first(where: { $0.nodeId == id }) { + if !node.interactiveOptions.isEmpty { + guard let chosenNextID = selectedPaths[id], + node.interactiveOptions.contains(where: { $0.nextNodeId == chosenNextID }) + else { return false } + nodeID = chosenNextID + } else { + nodeID = scenario.nextNodeId(for: node) + } + } + return true + } + public var visibleTranscriptRows: [ClassBattleTranscriptRow] { let lines = visibleTranscript return lines.enumerated().map { index, line in @@ -202,7 +300,7 @@ public struct ClassBattleFlowFeature { ?? voteHistory.postVote.map({ $0.optionID != voteHistory.preVote.optionID }) else { return nil } - return changed ? "생각이 바뀌었어요" : "처음 생각을 유지했어요" + return changed ? "생각이 바뀌었어요" : "생각이 동일해요" } public var displayArtworkURL: URL? { @@ -226,9 +324,15 @@ public struct ClassBattleFlowFeature { public enum Action: ViewAction { case view(View) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + @CasePathable public enum View: Equatable { case onAppear @@ -248,23 +352,35 @@ public struct ClassBattleFlowFeature { case scrub(TimeInterval) case replayTapped case playbackRateSelected(Float) + case midVoteOptionTapped(Int) + case confirmMidVoteTapped case perspectiveOptionTapped(Int?) case perspectiveSortTapped(String) case perspectiveTapped(Int) case perspectiveLikeTapped(Int) + case perspectiveMoreTapped(Int) + case perspectiveEditTapped(Int) + case perspectiveDeleteTapped(Int) + case perspectiveReportTapped(Int) + case cancelEditTapped + case toastShown } public enum InnerAction: Equatable { case progressResponse(UUID, Result) case submissionResponse(UUID, ClassBattleStep, Result) case audioURLResponse(UUID, Result) - case transcriptLoaded(UUID, [ClassBattleTranscriptLine]) + case scenarioLoaded(UUID, BattleScenario) + case midVoteResponse(UUID, Int, Int, Result) case audioLoaded(UUID, Bool, TimeInterval) case playerTimeUpdated(UUID, TimeInterval) case perspectivePageResponse(UUID, Result) case voteStatsResponse(UUID, Result) case perspectiveLikeResponse(UUID, Int, Result) case myPerspectiveResponse(UUID, Result<[ClassPerspectiveItem], ClassBattleError>) + case updatePerspectiveResponse(UUID, Result) + case deletePerspectiveResponse(UUID, Int, Result) + case reportPerspectiveResponse(UUID, Result) } @CasePathable @@ -284,6 +400,9 @@ public struct ClassBattleFlowFeature { case voteStats(UUID) case perspectiveLike(UUID) case myPerspective(UUID) + case midVote(UUID) + /// 의견 수정·삭제·신고. 상세 화면 이동(onDisappear)에서는 취소하지 않고 라우트 제거 시에만 취소한다. + case perspectiveMutation(UUID) } @Dependency(\.uuid) private var uuid @@ -297,9 +416,13 @@ public struct ClassBattleFlowFeature { switch action { case let .view(viewAction): return handleView(state: &state, action: viewAction) case let .inner(innerAction): return handleInner(state: &state, action: innerAction) + case let .scope(scopeAction): return handleScope(state: &state, action: scopeAction) case .delegate: return .none } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } @@ -324,8 +447,12 @@ extension ClassBattleFlowFeature { if state.step == .some(.completed), state.perspectiveRequestID == nil { let shouldRefresh = state.refreshAfterPerspectiveDetail + let returnedFromOwnPerspective = shouldRefresh && state.refreshAfterOwnPerspectiveDetail state.refreshAfterPerspectiveDetail = false + state.refreshAfterOwnPerspectiveDetail = false if shouldRefresh { state.hasSubmittedInCurrentSession = false } + // 내 의견 상세에서 삭제 후 돌아왔을 수 있으므로 /me 결과로 다시 판정한다. + if returnedFromOwnPerspective { state.hasPublishedPerspective = nil } return .merge( state.perspectives.isEmpty || shouldRefresh ? loadPerspectives(state: &state, cursor: nil, preserveExisting: shouldRefresh) : .none, @@ -354,6 +481,8 @@ extension ClassBattleFlowFeature { state.pendingPerspectiveLikeResults = [:] state.voteStatsRequestID = nil state.myPerspectiveRequestID = nil + state.midVoteRequestID = nil + state.menuTargetPerspectiveID = nil state.isLoadingPerspectives = false state.isPlaying = false return stopAudio(instanceID: state.instanceID) @@ -373,7 +502,12 @@ extension ClassBattleFlowFeature { guard state.step == .some(.completed), state.hasPublishedPerspective == nil, state.myPerspectiveRequestID == nil else { return .none } state.perspectiveErrorMessage = nil - return loadMyPerspectives(state: &state) + // 목록도 실패해 비어 있으면 함께 다시 불러온다. 오류 문구만 지우면 빈 목록처럼 보인다. + let reloadsPerspectives = state.perspectives.isEmpty && state.perspectiveRequestID == nil + return .merge( + reloadsPerspectives ? loadPerspectives(state: &state, cursor: nil) : .none, + loadMyPerspectives(state: &state) + ) case .backTapped: let instanceID = state.instanceID return .concatenate( @@ -384,6 +518,7 @@ extension ClassBattleFlowFeature { guard state.canParticipate, let step = state.step, [.none, .preVote, .postVote].contains(step), + !(step == .preVote && state.isPreVoteLocked), state.options.contains(where: { $0.id == optionID }), state.submitRequestID == nil else { return .none } @@ -404,6 +539,9 @@ extension ClassBattleFlowFeature { return .none case .submitTapped: guard state.canSubmit else { return .none } + if let editingID = state.editingPerspectiveID { + return updatePerspective(state: &state, id: editingID) + } guard let step = state.step else { return .none } let requestID = uuid() state.submitRequestID = requestID @@ -411,6 +549,7 @@ extension ClassBattleFlowFeature { guard let classID = state.room?.id else { return .none } let optionID = state.selectedOptionID let preVoteOptionID = state.voteHistory?.preVote.optionID + let isPreVoteLocked = state.isPreVoteLocked let draft = state.draft.trimmingCharacters(in: .whitespacesAndNewlines) return .run { [useCase = classUseCase] send in let result = await Result { @@ -419,7 +558,7 @@ extension ClassBattleFlowFeature { guard let optionID else { throw ClassBattleError.invalidOption } _ = try await useCase.submitPreVote(classID: classID, optionID: optionID) case .preVote: - if optionID != preVoteOptionID { + if !isPreVoteLocked && optionID != preVoteOptionID { guard let optionID else { throw ClassBattleError.invalidOption } _ = try await useCase.submitPreVote(classID: classID, optionID: optionID) } else { @@ -439,26 +578,78 @@ extension ClassBattleFlowFeature { await send(.inner(.submissionResponse(requestID, step, result))) } .cancellable(id: CancelID.submit(state.instanceID), cancelInFlight: true) + case let .midVoteOptionTapped(nextNodeID): + guard state.pendingCheckpointID != nil, + state.midVoteOptions.contains(where: { $0.nextNodeId == nextNodeID }), + state.midVoteRequestID == nil else { return .none } + state.selectedMidOptionID = nextNodeID + state.errorMessage = nil + return .none + case .confirmMidVoteTapped: + guard state.canConfirmMidVote, + let checkpointID = state.pendingCheckpointID, + let nextNodeID = state.selectedMidOptionID, + let option = state.midVoteOptions.first(where: { $0.nextNodeId == nextNodeID }), + let classID = state.room?.id else { return .none } + guard let session = state.session else { return .none } + if !session.midVoteEnabled || state.midVoteDisabledLocally { + return advanceAfterChoice(state: &state, checkpointID: checkpointID, option: option) + } + guard let optionID = option.optionID else { + state.errorMessage = ClassBattleError.invalidResponse.message + return .none + } + let requestID = uuid() + state.midVoteRequestID = requestID + state.errorMessage = nil + return .run { [useCase = classUseCase] send in + let result = await Result { + _ = try await useCase.submitMidVote( + classID: classID, + checkpointID: checkpointID, + optionID: optionID + ) + return true + }.mapError(Self.mapError) + guard !Task.isCancelled else { return } + await send(.inner(.midVoteResponse(requestID, checkpointID, optionID, result))) + } + .cancellable(id: CancelID.midVote(state.instanceID), cancelInFlight: true) case .playbackTapped: - guard state.isAudioStep, state.isAudioReady else { return .none } + guard state.isAudioStep, state.isAudioReady, + state.pendingCheckpointID == nil else { return .none } state.isPlaying.toggle() return .run { [player = audioPlayer, shouldPlay = state.isPlaying] _ in if shouldPlay { await player.play() } else { await player.pause() } } .cancellable(id: CancelID.playback(state.instanceID), cancelInFlight: true) case let .seekBy(delta): - guard state.isAudioStep, state.isAudioReady else { return .none } - let target = min(state.audioDuration, max(0, state.currentTime + delta)) + guard state.isAudioStep, state.isAudioReady, + state.pendingCheckpointID == nil else { return .none } + let checkpointBoundary = state.currentNode.flatMap { node in + node.interactiveOptions.isEmpty ? nil : state.scenario?.nodeEndTime(for: node) + } + let target = min( + state.audioDuration, + min(checkpointBoundary ?? .infinity, max(0, state.currentTime + delta)) + ) state.currentTime = target + state.pendingSeekTime = target return .run { [player = audioPlayer] _ in await player.seek(to: target) } case let .scrub(time): - guard state.isAudioStep, state.isAudioReady, state.hasListened else { return .none } + guard state.isAudioStep, state.isAudioReady, state.hasListened, + state.pendingCheckpointID == nil else { return .none } let target = min(state.audioDuration, max(0, time)) state.currentTime = target + state.pendingSeekTime = target return .run { [player = audioPlayer] _ in await player.seek(to: target) } case .replayTapped: guard state.isAudioStep, state.isAudioReady, state.hasListened else { return .none } state.currentTime = 0 + state.currentNodeID = state.scenario?.startNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + state.pendingSeekTime = 0 state.isPlaying = true return .run { [player = audioPlayer] _ in await player.seek(to: 0) @@ -482,6 +673,8 @@ extension ClassBattleFlowFeature { guard state.step == .some(.completed), state.submitRequestID == nil, state.perspectives.contains(where: { $0.id == id }) else { return .none } state.refreshAfterPerspectiveDetail = true + state.refreshAfterOwnPerspectiveDetail = + state.perspectives.first(where: { $0.id == id })?.isMyPerspective == true return .send(.delegate(.openPerspective(id))) case let .perspectiveLikeTapped(id): guard state.step == .some(.completed), state.canParticipate, @@ -500,7 +693,122 @@ extension ClassBattleFlowFeature { await send(.inner(.perspectiveLikeResponse(requestID, id, result))) } .cancellable(id: CancelID.perspectiveLike(state.instanceID), cancelInFlight: true) + case let .perspectiveMoreTapped(id): + // 진행 중인 수정·삭제·신고가 같은 CancelID로 취소되지 않게 한 번에 하나만 허용한다. + guard state.mutationRequestID == nil, state.perspectiveMenuKind(id: id).hasMenu else { return .none } + state.menuTargetPerspectiveID = state.menuTargetPerspectiveID == id ? nil : id + return .none + case let .perspectiveEditTapped(id): + state.menuTargetPerspectiveID = nil + guard state.mutationRequestID == nil, + [.editOnly, .editAndDelete].contains(state.perspectiveMenuKind(id: id)), + let item = state.perspectiveItem(id: id) else { return .none } + if state.editingPerspectiveID != id { + state.draft = item.content + } + state.editingPerspectiveID = id + state.errorMessage = nil + return .none + case let .perspectiveDeleteTapped(id): + state.menuTargetPerspectiveID = nil + guard state.mutationRequestID == nil, state.perspectiveMenuKind(id: id) == .editAndDelete else { return .none } + state.deleteTargetPerspectiveID = id + state.reportTargetPerspectiveID = nil + state.customAlert = .deletePerspective() + return .none + case let .perspectiveReportTapped(id): + state.menuTargetPerspectiveID = nil + guard state.mutationRequestID == nil, state.perspectiveMenuKind(id: id) == .report else { return .none } + state.reportTargetPerspectiveID = id + state.deleteTargetPerspectiveID = nil + state.customAlert = .report() + return .none + case .cancelEditTapped: + state.editingPerspectiveID = nil + state.draft = "" + return .none + case .toastShown: + state.toastMessage = nil + return .none + } + } + + private func handleScope( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .customAlert(.presented(.confirmTapped)): + state.customAlert = nil + guard state.mutationRequestID == nil else { + state.deleteTargetPerspectiveID = nil + state.reportTargetPerspectiveID = nil + return .none + } + if let id = state.deleteTargetPerspectiveID { + state.deleteTargetPerspectiveID = nil + return deletePerspective(state: &state, id: id) + } + if let id = state.reportTargetPerspectiveID { + state.reportTargetPerspectiveID = nil + return reportPerspective(state: &state, id: id) + } + return .none + case .customAlert(.presented(.cancelTapped)), .customAlert(.dismiss): + state.customAlert = nil + state.deleteTargetPerspectiveID = nil + state.reportTargetPerspectiveID = nil + return .none + } + } + + private func updatePerspective( + state: inout State, + id: Int + ) -> Effect { + let requestID = uuid() + state.mutationRequestID = requestID + let content = state.draft.trimmingCharacters(in: .whitespacesAndNewlines) + return .run { [useCase = perspectiveUseCase] send in + let result = await Result { + try await useCase.updatePerspective(perspectiveId: id, content: content) + return true + }.mapError(PerspectiveError.from) + await send(.inner(.updatePerspectiveResponse(requestID, result))) + } + .cancellable(id: CancelID.perspectiveMutation(state.instanceID), cancelInFlight: true) + } + + private func deletePerspective( + state: inout State, + id: Int + ) -> Effect { + let requestID = uuid() + state.mutationRequestID = requestID + return .run { [useCase = perspectiveUseCase] send in + let result = await Result { + try await useCase.deletePerspective(perspectiveId: id) + return true + }.mapError(PerspectiveError.from) + await send(.inner(.deletePerspectiveResponse(requestID, id, result))) + } + .cancellable(id: CancelID.perspectiveMutation(state.instanceID), cancelInFlight: true) + } + + private func reportPerspective( + state: inout State, + id: Int + ) -> Effect { + let requestID = uuid() + state.mutationRequestID = requestID + return .run { [useCase = perspectiveUseCase] send in + let result = await Result { + try await useCase.reportPerspective(perspectiveId: id) + return true + }.mapError(PerspectiveError.from) + await send(.inner(.reportPerspectiveResponse(requestID, result))) } + .cancellable(id: CancelID.perspectiveMutation(state.instanceID), cancelInFlight: true) } private func loadProgress(state: inout State) -> Effect { @@ -536,6 +844,17 @@ extension ClassBattleFlowFeature { state.viewState = .loaded state.session = progress.session state.voteHistory = progress.voteHistory + if progress.session.midVoteEnabled { + state.selectedPaths = state.scenario.map { + Self.selectedPaths(from: progress.voteHistory, scenario: $0) + } ?? [:] + if let scenario = state.scenario { + state.transcript = ClassBattleTranscriptLine.from( + scenario: scenario, + selectedPaths: state.selectedPaths + ) + } + } if progress.session.battle.currentStep == .completed { if state.hasSubmittedInCurrentSession || (progress.session.commentRequired && progress.session.participationCompleted) { @@ -561,7 +880,8 @@ extension ClassBattleFlowFeature { stopPlayback(instanceID: state.instanceID), loadPerspectives(state: &state, cursor: nil), loadVoteStats(state: &state), - state.hasPublishedPerspective == nil ? loadMyPerspectives(state: &state) : .none + // 공개되지 않은 내 의견 카드·거절 의견 수정을 위해 완료 단계마다 내 의견을 다시 받는다(Android 제출 후 fetchMyPerspective). + loadMyPerspectives(state: &state) ) } return stopPlayback(instanceID: state.instanceID) @@ -577,6 +897,7 @@ extension ClassBattleFlowFeature { case .success: state.draft = "" state.hasAdvancedInCurrentSession = true + if submittedStep == .postVote { state.needsMidVoteRecovery = false } if submittedStep == .completed { state.hasSubmittedInCurrentSession = true state.hasPublishedPerspective = true @@ -584,12 +905,35 @@ extension ClassBattleFlowFeature { return loadProgress(state: &state) case let .failure(error): state.errorMessage = error.message + if submittedStep == .postVote, error == .midVoteRequired { + state.needsMidVoteRecovery = true + state.hasListened = false + state.currentNodeID = state.scenario?.startNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + state.currentTime = 0 + state.pendingSeekTime = 0 + if state.isAudioReady { + state.isPlaying = true + return .merge( + .run { [player = audioPlayer] _ in + await player.seek(to: 0) + await player.play() + }, + observePlayerTime(instanceID: state.instanceID) + ) + } + if let battleID = state.session?.battle.id { + return loadAudio(state: &state, battleID: battleID) + } + } return .none } case let .audioURLResponse(requestID, result): guard state.audioRequestID == requestID, state.isAudioStep else { return .none } switch result { case let .success(url): + state.loadedAudioURL = url return .run { [player = audioPlayer] send in let loaded = await player.load(url: url) let duration = loaded ? await player.duration() : 0 @@ -602,6 +946,47 @@ extension ClassBattleFlowFeature { state.audioRequestID = nil return .none } + case let .scenarioLoaded(requestID, scenario): + guard state.audioRequestID == requestID, state.isAudioStep else { return .none } + state.scenario = scenario + state.currentNodeID = scenario.startNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + if state.session?.midVoteEnabled == true { + state.selectedPaths = Self.selectedPaths(from: state.voteHistory, scenario: scenario) + } + state.transcript = ClassBattleTranscriptLine.from( + scenario: scenario, + selectedPaths: state.selectedPaths + ) + return .none + case let .midVoteResponse(requestID, checkpointID, optionID, result): + guard state.midVoteRequestID == requestID, + state.pendingCheckpointID == checkpointID, + let option = state.midVoteOptions.first(where: { $0.optionID == optionID }) + else { return .none } + state.midVoteRequestID = nil + switch result { + case .success: + if state.step == .some(.preVote) { + state.selectedOptionID = state.voteHistory?.preVote.optionID + } + return advanceAfterChoice(state: &state, checkpointID: checkpointID, option: option) + case let .failure(error): + state.errorMessage = error.message + if error == .midVoteUnavailable { + let effect = advanceAfterChoice(state: &state, checkpointID: checkpointID, option: option) + state.midVoteDisabledLocally = true + state.errorMessage = error.message + return effect + } + if error == .midVoteLocked || error == .invalidMidVoteOption || error == .voteNotFound { + state.pendingCheckpointID = nil + state.isAudioReady = false + return .merge(stopPlayback(instanceID: state.instanceID), loadProgress(state: &state)) + } + return .none + } case let .audioLoaded(requestID, loaded, duration): guard state.audioRequestID == requestID, state.isAudioStep else { return .none } state.audioRequestID = nil @@ -612,20 +997,28 @@ extension ClassBattleFlowFeature { state.isAudioReady = true state.audioDuration = duration state.isPlaying = true + let seekTime = state.pendingSeekTime return .merge( - .run { [player = audioPlayer] _ in await player.play() } + .run { [player = audioPlayer] _ in + if let seekTime { await player.seek(to: seekTime) } + await player.play() + } .cancellable(id: CancelID.playback(state.instanceID), cancelInFlight: true), observePlayerTime(instanceID: state.instanceID) ) - case let .transcriptLoaded(requestID, transcript): - guard state.audioRequestID == requestID, state.isAudioStep else { return .none } - state.transcript = transcript - return .none case let .playerTimeUpdated(instanceID, time): guard state.instanceID == instanceID, state.isAudioStep, - state.isAudioReady else { return .none } + state.isAudioReady, state.pendingCheckpointID == nil else { return .none } + if let pendingSeekTime = state.pendingSeekTime { + guard abs(time - pendingSeekTime) < 1 else { return .none } + state.pendingSeekTime = nil + } state.currentTime = min(state.audioDuration, max(0, time)) - guard time >= max(0, state.audioDuration - 0.3) else { return .none } + if let effect = advanceNodeIfNeeded(state: &state, time: time) { + return effect + } + guard state.pendingCheckpointID == nil, + time >= max(0, state.audioDuration - 0.3) else { return .none } state.hasListened = true guard state.isPlaying else { return .none } state.isPlaying = false @@ -687,9 +1080,64 @@ extension ClassBattleFlowFeature { state.myPerspectiveRequestID = nil switch result { case let .success(items): - state.hasPublishedPerspective = !items.isEmpty || state.hasSubmittedInCurrentSession + state.myPerspective = items.first + // 서버가 참여 완료로 판정한 뒤에는 /me가 비어도 입력창을 다시 열지 않는다(중복 의견 방지). + state.hasPublishedPerspective = state.hasPublishedPerspective == true + || !items.isEmpty + || state.hasSubmittedInCurrentSession case let .failure(error): - state.perspectiveErrorMessage = error.message + // 판정 전 실패만 재시도 문구를 띄운다. 이미 판정됐으면 목록 오류 문구를 덮지 않는다. + if state.hasPublishedPerspective == nil { + state.perspectiveErrorMessage = error.message + } + } + return .none + case let .updatePerspectiveResponse(requestID, result): + guard state.mutationRequestID == requestID else { return .none } + state.mutationRequestID = nil + switch result { + case .success: + state.editingPerspectiveID = nil + state.draft = "" + return .merge( + loadPerspectives(state: &state, cursor: nil, preserveExisting: true), + loadMyPerspectives(state: &state) + ) + case .failure: + state.toastMessage = "의견을 수정하지 못했어요" + return .none + } + case let .deletePerspectiveResponse(requestID, id, result): + guard state.mutationRequestID == requestID else { return .none } + state.mutationRequestID = nil + switch result { + case .success: + // Android와 같이 삭제한 내 의견을 바로 빼고 새 의견을 다시 쓸 수 있게 한다. + state.perspectives.removeAll { $0.id == id } + if state.myPerspective?.id == id { state.myPerspective = nil } + if state.editingPerspectiveID == id { + state.editingPerspectiveID = nil + state.draft = "" + } + state.hasPublishedPerspective = false + state.hasSubmittedInCurrentSession = false + case .failure: + state.toastMessage = "의견을 삭제하지 못했어요" + } + return .merge( + loadPerspectives(state: &state, cursor: nil, preserveExisting: true), + loadMyPerspectives(state: &state) + ) + case let .reportPerspectiveResponse(requestID, result): + guard state.mutationRequestID == requestID else { return .none } + state.mutationRequestID = nil + switch result { + case .success: + state.toastMessage = "신고가 정상 접수되었습니다." + case .failure(.alreadyReported): + state.toastMessage = "이미 신고한 사용자입니다." + case .failure: + state.toastMessage = "신고하지 못했어요" } return .none } @@ -764,22 +1212,28 @@ extension ClassBattleFlowFeature { let requestID = uuid() state.audioRequestID = requestID state.errorMessage = nil - return .run { [useCase = battleUseCase] send in + return .run { [ + useCase = battleUseCase, + history = state.voteHistory, + priorPaths = state.selectedPaths + ] send in let result = await Result { let scenario = try await useCase.fetchScenario(battleId: battleID) - guard let rawURL = scenario.audios[scenario.recommendedPathKey.rawValue] + let paths = Self.selectedPaths(from: history, scenario: scenario) + let chosenNodeID = (paths.isEmpty ? priorPaths : paths).values.first + let pathKey = chosenNodeID.flatMap { Self.branchPathKey(from: $0, scenario: scenario) } + guard let rawURL = pathKey.flatMap({ scenario.audios[$0] }) + ?? scenario.audios[scenario.recommendedPathKey.rawValue] ?? scenario.audios.values.first, let url = URL(string: rawURL), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { throw ClassBattleError.invalidResponse } - return (url, ClassBattleTranscriptLine.from(scenario: scenario)) + return (url, scenario) }.mapError(Self.mapError) guard !Task.isCancelled else { return } switch result { - case let .success((url, transcript)): - if !transcript.isEmpty { - await send(.inner(.transcriptLoaded(requestID, transcript))) - } + case let .success((url, scenario)): + await send(.inner(.scenarioLoaded(requestID, scenario))) await send(.inner(.audioURLResponse(requestID, .success(url)))) case let .failure(error): await send(.inner(.audioURLResponse(requestID, .failure(error)))) @@ -788,6 +1242,113 @@ extension ClassBattleFlowFeature { .cancellable(id: CancelID.audio(state.instanceID), cancelInFlight: true) } + private func advanceNodeIfNeeded( + state: inout State, + time: TimeInterval + ) -> Effect? { + guard let scenario = state.scenario, state.pendingCheckpointID == nil else { return nil } + var visited = Set() + while let node = state.currentNode, visited.insert(node.nodeId).inserted, + time >= scenario.nodeEndTime(for: node) { + if !node.interactiveOptions.isEmpty { + if let selectedNextID = state.selectedPaths[node.nodeId], + let selectedOption = node.interactiveOptions.first(where: { $0.nextNodeId == selectedNextID }) { + return advanceAfterChoice(state: &state, checkpointID: node.nodeId, option: selectedOption) + } + state.pendingCheckpointID = node.nodeId + state.selectedMidOptionID = state.selectedPaths[node.nodeId] + state.currentTime = scenario.nodeEndTime(for: node) + state.isPlaying = false + return .run { [player = audioPlayer] _ in await player.pause() } + } + guard let nextNodeID = scenario.nextNodeId(for: node), + scenario.nodes.contains(where: { $0.nodeId == nextNodeID }) + else { return nil } + state.currentNodeID = nextNodeID + } + return nil + } + + private func advanceAfterChoice( + state: inout State, + checkpointID: Int, + option: ScenarioInteractiveOption + ) -> Effect { + guard let scenario = state.scenario else { return .none } + let pathKey = Self.branchPathKey(from: option.nextNodeId, scenario: scenario) + let branchURL: URL? + if let pathKey, let rawURL = scenario.audios[pathKey] { + branchURL = URL(string: rawURL) + } else if pathKey != nil, + scenario.audios["PATH_A"] != nil || scenario.audios["PATH_B"] != nil { + state.errorMessage = ClassBattleError.invalidResponse.message + return .none + } else { + branchURL = state.loadedAudioURL + } + state.selectedPaths[checkpointID] = option.nextNodeId + state.transcript = ClassBattleTranscriptLine.from( + scenario: scenario, + selectedPaths: state.selectedPaths + ) + state.currentNodeID = option.nextNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + state.errorMessage = nil + let targetTime = scenario.nodeStartTime(for: option.nextNodeId) + state.currentTime = targetTime + state.pendingSeekTime = targetTime + state.isPlaying = true + if let branchURL, branchURL != state.loadedAudioURL { + let requestID = uuid() + state.audioRequestID = requestID + state.loadedAudioURL = branchURL + state.isAudioReady = false + state.isPlaying = false + return .run { [player = audioPlayer] send in + let loaded = await player.load(url: branchURL) + let duration = loaded ? await player.duration() : 0 + guard !Task.isCancelled else { return } + await send(.inner(.audioLoaded(requestID, loaded, duration))) + } + .cancellable(id: CancelID.audio(state.instanceID), cancelInFlight: true) + } + return .run { [player = audioPlayer] _ in + await player.seek(to: targetTime) + await player.play() + } + } + + private static func selectedPaths( + from history: ClassVoteHistory?, + scenario: BattleScenario + ) -> [Int: Int] { + Dictionary( + history?.midVotes.compactMap { choice in + scenario.nodes.first(where: { $0.nodeId == choice.checkpointID })? + .interactiveOptions.first(where: { $0.optionID == choice.optionID }) + .map { (choice.checkpointID, $0.nextNodeId) } + } ?? [], + uniquingKeysWith: { _, newest in newest } + ) + } + + private static func branchPathKey( + from firstNodeID: Int, + scenario: BattleScenario + ) -> String? { + var nodeID: Int? = firstNodeID + var visited = Set() + while let id = nodeID, visited.insert(id).inserted, + let node = scenario.nodes.first(where: { $0.nodeId == id }) { + let name = node.nodeName.uppercased() + if name.contains("_A") { return "PATH_A" } + if name.contains("_B") { return "PATH_B" } + nodeID = node.autoNextNodeId + } + return nil + } + private func observePlayerTime(instanceID: UUID) -> Effect { .run { [player = audioPlayer] send in for await time in player.currentTimes() { @@ -802,6 +1363,7 @@ extension ClassBattleFlowFeature { .merge( .cancel(id: CancelID.load(instanceID)), .cancel(id: CancelID.submit(instanceID)), + .cancel(id: CancelID.midVote(instanceID)), .cancel(id: CancelID.perspectives(instanceID)), .cancel(id: CancelID.perspectiveLike(instanceID)), .cancel(id: CancelID.myPerspective(instanceID)), diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift index 299b339d..90123f09 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift @@ -22,7 +22,10 @@ public struct ClassBattleFlowView: View { case .error: errorScreen() case .loaded: switch store.step { - case .some(.none), .some(.postVote): voteScreen() + case .some(.none): voteScreen() + case .some(.postVote): + if store.needsMidVoteRecovery { listeningScreen() } + else { voteScreen() } case .some(.preVote): listeningScreen() case .some(.ttsListening): listeningScreen() case .some(.completed): @@ -35,13 +38,18 @@ public struct ClassBattleFlowView: View { .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } .onDisappear { send(.onDisappear) } + .onChange(of: store.toastMessage) { _, message in + guard let message else { return } + ToastManager.shared.showInfo(message) + send(.toastShown) + } } } private extension ClassBattleFlowView { var isFinalVote: Bool { store.step == .some(.postVote) } var isPreVoteLanding: Bool { store.step == .some(.completed) && store.showsPreVoteLanding } - var voteBackground: Color { isFinalVote ? .black : .white } + var voteBackground: Color { isFinalVote ? .gray900 : .white } @ViewBuilder func loadingScreen() -> some View { @@ -90,20 +98,20 @@ private extension ClassBattleFlowView { height: isPreVoteLanding ? max(420, min(510, proxy.size.height * 0.63)) : isFinalVote - ? max(370, min(446, proxy.size.height * 0.56)) - : max(315, min(390, proxy.size.height * 0.48)) + ? max(330, min(405, proxy.size.height * 0.5 + 15)) + : max(315, min(390, proxy.size.height * 0.5)) ) voteIntroduction() if !isPreVoteLanding { optionSection() - .padding(.top, isFinalVote ? 36 : 40) + .padding(.top, 40) } } .padding(.horizontal, 16) .frame(minHeight: max(0, proxy.size.height - 110), alignment: .top) } .safeAreaInset(edge: .bottom, spacing: 0) { - voteFooter() + voteFooter(bottomPadding: voteFooterBottomPadding(hasBottomSafeArea: proxy.safeAreaInsets.bottom > 0)) .padding(.horizontal, 16) .padding(.top, 12) .background(voteBackground) @@ -179,39 +187,63 @@ private extension ClassBattleFlowView { @ViewBuilder func voteIntroduction() -> some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { if let category = store.voteCategoryLabel { - Text(category) - .pretendardFont(.medium11) - .foregroundStyle(isFinalVote ? .beige200 : .primary500) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(isFinalVote ? Color.clear : .beige200) + voteCategoryBadge(category) } Spacer(minLength: 0) if !isFinalVote, let commentCount = store.voteCommentCountLabel { - Label(commentCount, systemImage: "bubble.right") - .pretendardFont(.medium11) - .foregroundStyle(.gray500) - .padding(.horizontal, 5) - .padding(.vertical, 3) - .overlay(Rectangle().stroke(.beige500, lineWidth: 1)) + voteCommentCountBadge(commentCount) } } Text(store.displayTitle) - .pretendardFont(.bold24) + .pretendardFont(.bold24, lineHeightMultiple: 1.3) .foregroundStyle(isFinalVote ? .white : .gray800) .fixedSize(horizontal: false, vertical: true) + .padding(.top, 20) Text(store.displaySummary) - .pretendardFont(.bodyMedium) - .foregroundStyle(isFinalVote ? .beige200 : .gray500) + .pretendardFont(.bodyMedium, lineHeightMultiple: 1.4) + .foregroundStyle(isFinalVote ? .gray300 : .gray400) .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) } .frame(maxWidth: .infinity, alignment: .leading) } + func voteCategoryBadge(_ category: String) -> some View { + Text(category) + .pretendardFont(.semiBold12) + .foregroundStyle(.badgeFilledText) + .lineLimit(1) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.badgeFilledBackground, in: RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + func voteCommentCountBadge(_ count: String) -> some View { + HStack(spacing: 2) { + Image(asset: .commentMessage) + .resizable() + .frame(width: 16, height: 16) + .accessibilityHidden(true) + Text(count) + .pretendardFont(.labelSmall) + .fixedSize() + } + .foregroundStyle(.gray300) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(.white, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(.gray100, lineWidth: 1) + } + } + @ViewBuilder func optionSection(isPreVoteDraft: Bool = false) -> some View { ZStack { @@ -223,11 +255,10 @@ private extension ClassBattleFlowView { if store.options.count == 2 { Text("VS") - .pretendardFont(.bold11) - .foregroundStyle(.neutral800) + .pretendardFont(.semiBold11) + .foregroundStyle(.gray800) .frame(width: 28, height: 28) .background(.secondary200, in: Circle()) - .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) .allowsHitTesting(false) } } @@ -238,20 +269,21 @@ private extension ClassBattleFlowView { _ option: ClassBattleOption, isPreVoteDraft: Bool ) -> some View { + let isSelected = (isPreVoteDraft ? preVoteDraftOptionID : store.selectedOptionID) == option.id Button { if isPreVoteDraft { preVoteDraftOptionID = option.id } else { send(.optionTapped(option.id)) } } label: { - VStack(spacing: 8) { + VStack(spacing: 0) { Group { if let url = option.imageURL { AsyncImage(url: url) { image in image.resizable().scaledToFill() } placeholder: { - Circle().fill(.beige400) + Circle().fill(.beige600) } } else { - Circle().fill(.beige400) + Circle().fill(.beige600) .overlay { Text(option.label ?? "") .pretendardFont(.semiBold13) @@ -262,36 +294,36 @@ private extension ClassBattleFlowView { .clipShape(Circle()) Text(option.title) .pretendardFont(.headingSmall) - .foregroundStyle(.neutral600) + .foregroundStyle(.gray800) .lineLimit(2) .multilineTextAlignment(.center) + .frame(minHeight: 20) + .padding(.top, 12) Text(option.representative) - .pretendardFont(.labelSmall) + .pretendardFont(.labelSmall, lineHeightMultiple: 1.4) .foregroundStyle(.neutral300) .lineLimit(1) + .padding(.top, 2) } .frame(maxWidth: .infinity) - .frame(height: 106) + .frame(height: 109) .background( - .beige300, - in: RoundedRectangle(cornerRadius: 3) + isSelected ? Color.beige400 : .beige300, + in: RoundedRectangle(cornerRadius: 2) ) .overlay { - RoundedRectangle(cornerRadius: 3) - .strokeBorder( - (isPreVoteDraft ? preVoteDraftOptionID : store.selectedOptionID) == option.id - ? (isFinalVote ? Color.secondary500 : .beige700) - : .beige500, - lineWidth: 1 - ) + RoundedRectangle(cornerRadius: 2) + .strokeBorder(isSelected ? Color.secondary500 : .beige600, lineWidth: 1) } + .opacity(isSelected ? 1 : 0.88) } + .accessibilityAddTraits(isSelected ? .isSelected : []) .buttonStyle(.plain) .disabled(!store.canParticipate) } @ViewBuilder - func voteFooter() -> some View { + func voteFooter(bottomPadding: CGFloat) -> some View { VStack(spacing: 8) { if let error = store.errorMessage { Text(error) @@ -308,12 +340,19 @@ private extension ClassBattleFlowView { deadline, systemImage: "clock" ) - .pretendardFont(.regular13) - .foregroundStyle(isFinalVote ? .beige200 : .gray500) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray300) } } .frame(maxWidth: .infinity) - .padding(.bottom, isFinalVote ? 10 : 20) + .padding(.bottom, bottomPadding) + } + + func voteFooterBottomPadding(hasBottomSafeArea: Bool) -> CGFloat { + if isFinalVote { return hasBottomSafeArea ? 6 : 10 } + // 홈 인디케이터가 있는 기기에서는 Figma처럼 마지막 줄인 마감 문구만 그 영역에 10pt 걸친다. + guard hasBottomSafeArea, store.voteDeadlineLabel != nil else { return 20 } + return -10 } @ViewBuilder @@ -340,12 +379,12 @@ private extension ClassBattleFlowView { LazyVStack(alignment: .leading, spacing: 6) { ForEach(rows) { row in transcriptRow(row) - .padding(.top, row.hasTopSpacing ? 16 : 0) + .padding(.top, row.hasTopSpacing ? 14 : 0) .id(row.id) } } .padding(.horizontal, 16) - .padding(.top, 24) + .padding(.top, 8) .padding(.bottom, 40) } .onChange(of: store.currentTime) { _, _ in @@ -356,6 +395,9 @@ private extension ClassBattleFlowView { } } } + if store.pendingCheckpointID != nil { + midVoteChoiceSection() + } audioFooter() } .screenBackground() @@ -365,6 +407,50 @@ private extension ClassBattleFlowView { } } + @ViewBuilder + func midVoteChoiceSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("선택의 시간") + .pretendardFont(.headingMedium) + .foregroundStyle(.neutral900) + ForEach(store.midVoteOptions) { option in + Button { + send(.midVoteOptionTapped(option.nextNodeId)) + } label: { + VStack(alignment: .leading, spacing: 4) { + if let title = option.title, !title.isEmpty, title != option.label { + Text(option.label) + .pretendardFont(.labelSmall) + .foregroundStyle(.neutral600) + } + Text(option.title.flatMap { $0.isEmpty ? nil : $0 } ?? option.label) + .pretendardFont(.bodyMedium) + .foregroundStyle(.neutral900) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + store.selectedMidOptionID == option.nextNodeId ? .beige300 : .white, + in: RoundedRectangle(cornerRadius: 4) + ) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder( + store.selectedMidOptionID == option.nextNodeId ? .primary500 : .beige600, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + } + Button("입장 선택하기") { send(.confirmMidVoteTapped) } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(!store.canConfirmMidVote) + } + .padding(16) + .background(.beige200) + } + @ViewBuilder func preVoteEditor() -> some View { VStack(alignment: .leading, spacing: 20) { @@ -403,8 +489,8 @@ private extension ClassBattleFlowView { .frame(maxWidth: .infinity) .padding(.vertical, 8) } else { - let bubbleBackground: Color = line.side == "B" ? .beige500 : .white - let bubbleBorder: Color = line.side == "B" ? .beige700 : .beige500 + let bubbleBackground: Color = line.side == "B" ? .beige400 : .white + let bubbleBorder: Color = .beige600 HStack(alignment: .top, spacing: 6) { if line.side != "B" { transcriptAvatar(row) @@ -412,16 +498,16 @@ private extension ClassBattleFlowView { VStack(alignment: line.side == "B" ? .trailing : .leading, spacing: 6) { if row.showsHeader { Text(line.speakerName) - .pretendardFont(.headingSmall) - .foregroundStyle(.gray400) + .pretendardFont(.headingSmall, lineHeightMultiple: 1.4) + .foregroundStyle(.gray800) } Text(line.text) - .pretendardFont(.labelSmall) - .foregroundStyle(.gray700) - .lineSpacing(4.8) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray400) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + .padding(.horizontal, 8) + .padding(.vertical, line.side == "B" ? 8 : 6) .pickeCard(bubbleBackground, border: bubbleBorder) .overlay(alignment: line.side == "B" ? .leading : .trailing) { if row.isActive && store.isPlaying { @@ -470,22 +556,26 @@ private extension ClassBattleFlowView { .pretendardFont(.regular13) .foregroundStyle(.primary500) Spacer() - Button("다시 시도") { send(.retryAudioTapped) } + if store.pendingCheckpointID == nil { + Button("다시 시도") { send(.retryAudioTapped) } + } } } - audioProgressBar() - AudioPlayerControlView( - isPlaying: .constant(store.isPlaying), - onBackward: { send(.seekBy(-15)) }, - onTogglePlay: { send(.playbackTapped) }, - onForward: { send(.seekBy(15)) }, - onReplay: { send(.replayTapped) }, - isReplayEnabled: store.hasListened, - tint: .neutral900, - playbackRateTitle: store.playbackRateTitle, - onPlaybackRateSelected: { send(.playbackRateSelected($0)) } - ) - .disabled(!store.isAudioReady) + VStack(spacing: 0) { + audioProgressBar() + AudioPlayerControlView( + isPlaying: .constant(store.isPlaying), + onBackward: { send(.seekBy(-15)) }, + onTogglePlay: { send(.playbackTapped) }, + onForward: { send(.seekBy(15)) }, + onReplay: { send(.replayTapped) }, + isReplayEnabled: store.hasListened, + tint: .neutral900, + playbackRateTitle: store.playbackRateTitle, + onPlaybackRateSelected: { send(.playbackRateSelected($0)) } + ) + .disabled(!store.isAudioReady || store.pendingCheckpointID != nil) + } if store.hasListened { Button(store.buttonTitle) { send(.submitTapped) } .ctaButtonStyle(.primary, size: .large, height: 52) @@ -493,28 +583,27 @@ private extension ClassBattleFlowView { } } .padding(.horizontal, 24) - .padding(.top, 16) + .padding(.top, 24) .padding(.bottom, 8) .background(.white) - .topDivider(.beige600) } @ViewBuilder func audioProgressBar() -> some View { - VStack(spacing: 8) { + VStack(spacing: 0) { GeometryReader { geometry in ZStack(alignment: .leading) { - Capsule().fill(.beige600).frame(height: 4) + Capsule().fill(.gray300).frame(height: 3) Capsule() .fill(.primary500) - .frame(width: geometry.size.width * store.audioProgress, height: 4) + .frame(width: geometry.size.width * store.audioProgress, height: 3) Circle() .fill(.primary500) - .frame(width: 16, height: 16) - .offset(x: max(0, geometry.size.width * store.audioProgress - 8)) + .frame(width: 11, height: 11) + .offset(x: max(0, geometry.size.width * store.audioProgress - 5.5)) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) + .contentShape(Rectangle().inset(by: -8)) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in @@ -525,13 +614,13 @@ private extension ClassBattleFlowView { ) .allowsHitTesting(store.hasListened) } - .frame(height: 16) + .frame(height: 11) HStack { Text(store.currentTimeText) Spacer() Text(store.audioDurationText) } - .pretendardFont(.medium11) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) .foregroundStyle(.neutral300) } } @@ -545,12 +634,16 @@ private extension ClassBattleFlowView { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { perspectiveSortRow() + if let row = store.hiddenMyPerspectiveRow { + perspectiveCard(row) + } ForEach(store.perspectiveRows) { row in perspectiveCard(row) } if store.isLoadingPerspectives { SkeletonView(.rect).frame(height: 90) - } else if store.perspectives.isEmpty && store.perspectiveErrorMessage == nil { + } else if store.perspectives.isEmpty && store.perspectiveErrorMessage == nil + && store.hiddenMyPerspectiveRow == nil { Text("아직 등록된 의견이 없어요.") .pretendardFont(.bodyMedium) .foregroundStyle(.gray500) @@ -583,6 +676,7 @@ private extension ClassBattleFlowView { } } .background(.white) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } @ViewBuilder @@ -595,36 +689,40 @@ private extension ClassBattleFlowView { } } .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 16) + .padding(.top, 8) + .padding(.bottom, 8) .frame(maxWidth: .infinity) - .background(.white) + .background(.beige200) } @ViewBuilder func voteStatsSummary(_ stats: ClassVoteStats) -> some View { let rows = store.voteStatsOptionRows - HStack(alignment: .center, spacing: 12) { + HStack(alignment: .top, spacing: 0) { if let first = rows.first { voteStatsOption(first) } - VStack(spacing: 20) { + // Figma는 높이 76 열에서 막대 영역(35)을 아래에 붙여 배지와 11 떨어진다. + VStack(spacing: 11) { if let opinionChangeLabel = store.opinionChangeLabel { Label( opinionChangeLabel, systemImage: "lightbulb" ) - .pretendardFont(.medium11) + .pretendardFont(.semiBold12) .foregroundStyle(.primary500) - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background(.beige200) + .padding(.horizontal, 4) + .frame(height: 20) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) .fixedSize() } else { Color.clear.frame(height: 20) } voteRatioBar(stats) + .padding(.horizontal, 8) + .frame(height: 35) } + .padding(.top, 10) .frame(maxWidth: .infinity) if rows.count > 1 { voteStatsOption(rows[1]) @@ -635,10 +733,10 @@ private extension ClassBattleFlowView { @ViewBuilder func voteRatioBar(_ stats: ClassVoteStats) -> some View { GeometryReader { geometry in - Capsule() - .fill(.beige300) + RoundedRectangle(cornerRadius: 2) + .fill(.beige500) .overlay(alignment: .leading) { - Capsule() + RoundedRectangle(cornerRadius: 2) .fill(.primary500) .frame( width: geometry.size.width * CGFloat(min(max(stats.options.first?.ratio ?? 0, 0), 100) / 100) @@ -651,7 +749,7 @@ private extension ClassBattleFlowView { @ViewBuilder func voteStatsOption(_ row: ClassVoteStatsOptionRow) -> some View { let option = row.option - VStack(spacing: 2) { + VStack(spacing: 0) { AsyncImage(url: option.imageURL) { image in image.resizable().scaledToFill() } placeholder: { @@ -660,17 +758,18 @@ private extension ClassBattleFlowView { .foregroundStyle(.gray800) } .frame(width: 40, height: 40) - .background(.beige200, in: Circle()) + .background(.beige600, in: Circle()) .clipShape(Circle()) Text(option.title) - .pretendardFont(.medium11) - .foregroundStyle(.gray800) + .pretendardFont(.medium11, lineHeightMultiple: 1.4) + .foregroundStyle(.gray500) .lineLimit(1) + .padding(.top, 4) Text(row.percentageLabel) - .pretendardFont(.semiBold13) - .foregroundStyle(.gray800) + .pretendardFont(.semiBold12, lineHeightMultiple: 1.4) + .foregroundStyle(.gray500) } - .frame(width: 64) + .frame(width: 70) } @ViewBuilder @@ -681,7 +780,7 @@ private extension ClassBattleFlowView { perspectiveFilter(title: option.title, optionID: option.id) } } - .background(.white) + .background(.beige200) } @ViewBuilder @@ -691,14 +790,16 @@ private extension ClassBattleFlowView { ) -> some View { Button { send(.perspectiveOptionTapped(optionID)) } label: { Text(title) - .pretendardFont(.semiBold13) + .pretendardFont(.labelMedium) .lineLimit(1) - .foregroundStyle(store.selectedPerspectiveOptionID == optionID ? .primary500 : .gray500) + .foregroundStyle(store.selectedPerspectiveOptionID == optionID ? .primary500 : .gray300) .frame(maxWidth: .infinity) - .padding(.vertical, 14) + .frame(height: 44) .overlay(alignment: .bottom) { if store.selectedPerspectiveOptionID == optionID { Rectangle().fill(.primary500).frame(height: 2) + } else { + Rectangle().fill(.gray100).frame(height: 1) } } } @@ -713,7 +814,8 @@ private extension ClassBattleFlowView { Spacer() } .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(.top, 16) + .padding(.bottom, 6) } @ViewBuilder @@ -723,11 +825,10 @@ private extension ClassBattleFlowView { ) -> some View { Button { send(.perspectiveSortTapped(value)) } label: { Text(title) - .pretendardFont(.medium11) + .pretendardFont(.medium13) .foregroundStyle(store.perspectiveSort == value ? .white : .primary500) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(store.perspectiveSort == value ? Color.primary500 : Color.white, in: RoundedRectangle(cornerRadius: 2)) + .frame(width: 58, height: 30) + .background(store.perspectiveSort == value ? Color.primary500 : .primary50, in: RoundedRectangle(cornerRadius: 2)) .overlay { RoundedRectangle(cornerRadius: 2) .stroke(.primary500, lineWidth: 1) @@ -740,56 +841,100 @@ private extension ClassBattleFlowView { func perspectiveCard(_ row: ClassPerspectiveRow) -> some View { let perspective = row.perspective VStack(alignment: .leading, spacing: 8) { - perspectiveHeader(perspective) + perspectiveHeader(row) VStack(alignment: .leading, spacing: 4) { Button { send(.perspectiveTapped(perspective.id)) } label: { Text(perspective.content) - .pretendardFont(.regular13) - .foregroundStyle(.gray500) - .lineSpacing(5) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray300) .lineLimit(3) + .padding(.horizontal, 2) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) + .disabled(row.statusBadgeTitle != nil) Button("자세히 보기") { send(.perspectiveTapped(perspective.id)) } - .pretendardFont(.labelSmall) + .pretendardFont(.labelSmall, lineHeightMultiple: 1.4) .foregroundStyle(.gray200) + .padding(.horizontal, 2) .buttonStyle(.plain) + .disabled(row.statusBadgeTitle != nil) } perspectiveActions(row) } .padding(.horizontal, 16) - .padding(.vertical, 18) + .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) - .background(.white) + .background(row.isPendingReview ? Color.secondary50 : row.isRejected ? .primary50 : .white) .overlay(alignment: .bottom) { - Rectangle().fill(.beige200).frame(height: 1) + Rectangle().fill(.beige600).frame(height: 1) + } + .overlay(alignment: .bottomTrailing) { + if store.menuTargetPerspectiveID == perspective.id { + perspectiveMenu(row) + .padding(.bottom, 40) + .padding(.trailing, 16) + } + } + .zIndex(store.menuTargetPerspectiveID == perspective.id ? 1 : 0) + } + + /// 더보기 메뉴 — Android 순서(내 의견: 삭제·수정, 거절: 수정, 남 의견: 신고). + @ViewBuilder + func perspectiveMenu(_ row: ClassPerspectiveRow) -> some View { + let id = row.perspective.id + VStack(alignment: .trailing, spacing: 8) { + switch row.menuKind { + case .report: + PickeActionPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.perspectiveReportTapped(id)) } + case .editOnly: + PickeActionPill(title: "수정", systemImage: "pencil") { send(.perspectiveEditTapped(id)) } + case .editAndDelete: + PickeActionPill(title: "삭제", systemImage: "trash") { send(.perspectiveDeleteTapped(id)) } + PickeActionPill(title: "수정", systemImage: "pencil") { send(.perspectiveEditTapped(id)) } + case .hidden: + EmptyView() + } } } @ViewBuilder - func perspectiveHeader(_ perspective: ClassPerspectiveItem) -> some View { + func perspectiveHeader(_ row: ClassPerspectiveRow) -> some View { + let perspective = row.perspective HStack(spacing: 8) { PickeAvatarView( imageURL: perspective.author.characterImageURL?.absoluteString, fallback: perspective.author.nickname, - size: 40 + size: 36 ) VStack(alignment: .leading, spacing: 2) { Text(perspective.author.nickname) - .pretendardFont(.semiBold13) + .pretendardFont(.semiBold12, lineHeightMultiple: 1.4) .foregroundStyle(.gray800) - Text(perspective.createdAt, style: .relative) - .pretendardFont(.regular11) - .foregroundStyle(.gray500) + Text(row.createdAtLabel) + .pretendardFont(.labelXSmall, lineHeightMultiple: 1.4) + .foregroundStyle(.gray300) } Spacer() - Text(perspective.option.title) - .pretendardFont(.medium11) - .foregroundStyle(.primary500) - .padding(.horizontal, 5) - .padding(.vertical, 3) - .background(.beige200) + if let statusTitle = row.statusBadgeTitle { + Text(statusTitle) + .pretendardFont(.semiBold12) + .foregroundStyle(.white) + .lineLimit(1) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(row.isRejected ? Color.primary400 : .secondary500, in: RoundedRectangle(cornerRadius: 2)) + } else { + Text(perspective.option.title) + .pretendardFont(.semiBold12) + .foregroundStyle(.badgeFilledText) + .lineLimit(1) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.badgeFilledBackground, in: RoundedRectangle(cornerRadius: 2)) + } } } @@ -806,7 +951,7 @@ private extension ClassBattleFlowView { Text(row.likeCountLabel) .pretendardFont(.labelSmall) } - .foregroundStyle(perspective.isLiked ? .primary500 : .gray500) + .foregroundStyle(perspective.isLiked ? .primary500 : .gray300) } .disabled(perspective.isMyPerspective != false) Button { send(.perspectiveTapped(perspective.id)) } label: { @@ -818,16 +963,34 @@ private extension ClassBattleFlowView { Text(row.commentCountLabel) .pretendardFont(.labelSmall) } - .foregroundStyle(.gray500) + .foregroundStyle(.gray300) } + // Android처럼 검수 중·거절된 의견은 상세로 이동하지 않는다. + .disabled(row.statusBadgeTitle != nil) Spacer() + if row.menuKind.hasMenu { + Button { send(.perspectiveMoreTapped(perspective.id)) } label: { + Image(systemName: "ellipsis") + .resizable() + .scaledToFit() + .frame(width: 15, height: 3) + .frame(width: 24, height: 24) + .contentShape(Rectangle().inset(by: -10)) + } + .foregroundStyle(.gray300) + .accessibilityLabel("더보기") + } } + .frame(height: 24) .buttonStyle(.plain) } @ViewBuilder func composerBar() -> some View { VStack(alignment: .leading, spacing: 0) { + if store.editingPerspectiveID != nil { + editingBanner() + } if let error = store.errorMessage { Text(error) .pretendardFont(.regular13) @@ -835,30 +998,55 @@ private extension ClassBattleFlowView { .padding(.horizontal, 16) .padding(.top, 12) } - HStack(spacing: 12) { + HStack(spacing: 8) { TextField( - "의견을 입력하세요", + "의견을 남겨보세요...", text: Binding( get: { store.draft }, set: { send(.draftChanged($0)) } - ) + ), + prompt: Text("의견을 남겨보세요...").foregroundStyle(.gray300) ) - .pretendardFont(.bodyMedium) + .pretendardFont(.medium13) .padding(.horizontal, 12) - .frame(height: 44) - .background(.white, in: RoundedRectangle(cornerRadius: 4)) + .frame(height: 52) + .background(.white, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(.beige600, lineWidth: 1) + } Button { send(.submitTapped) } label: { Image(systemName: "paperplane.fill") .foregroundStyle(.white) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) .background(.primary500, in: Circle()) + .contentShape(Rectangle().inset(by: -2)) } .buttonStyle(.plain) .disabled(!store.canSubmit) } .padding(16) } - .background(.beige200) + .background(.beige600) + .overlay(alignment: .top) { + Rectangle().fill(.beige700).frame(height: 1) + } + } + + @ViewBuilder + func editingBanner() -> some View { + HStack { + Text("의견 수정 중") + .pretendardFont(.semiBold12) + .foregroundStyle(.primary500) + Spacer() + Button("취소") { send(.cancelEditTapped) } + .pretendardFont(.medium13) + .foregroundStyle(.gray400) + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 12) } var plainNavigationBar: some View { @@ -876,12 +1064,12 @@ private extension ClassBattleFlowView { ) { Button { send(.reportTapped) } label: { Label("리포트", systemImage: "list.bullet.rectangle") - .pretendardFont(.medium11) + .pretendardFont(.bold11) } .buttonStyle(.plain) .foregroundStyle(.primary500) } - .background(.beige50) + .background(.beige200) .accessibilityAction(named: "뒤로 가기") { send(.backTapped) } } } diff --git a/Projects/Feature/Class/Sources/ClassIntro/Model/ClassCreateTicketNotice.swift b/Projects/Feature/Class/Sources/ClassIntro/Model/ClassCreateTicketNotice.swift new file mode 100644 index 00000000..f43a8b1e --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassIntro/Model/ClassCreateTicketNotice.swift @@ -0,0 +1,23 @@ +// +// ClassCreateTicketNotice.swift +// Class +// +// Created by Picke on 11/4/26. +// + +import PickeSharedUI + +/// 클래스 만들기 전 이용권 확인 결과로 띄우는 안내(Android `ClassTicketDialog`). +public enum ClassCreateTicketNotice: Equatable, Sendable { + /// 등록된 이용권이 없음(`TICKET_404`). 닫기만 한다. + case required + /// 사용 중이던 이용권이 만료됨. 등록하기로 이용권 등록 화면에 간다. + case expired + + public var alert: CustomAlertState { + switch self { + case .required: return .classTicketRequired() + case .expired: return .classTicketExpired() + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift index a7c656a3..0f4a599a 100644 --- a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift +++ b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift @@ -5,6 +5,7 @@ // Created by Picke on 10/11/26. // +import ClassDomainInterface import ComposableArchitecture import Foundation import PickeSharedUI @@ -16,6 +17,9 @@ public struct ClassIntroFeature { @ObservableState public struct State: Equatable { @Presents public var customAlert: CustomAlertState? + public var ticketNotice: ClassCreateTicketNotice? + public var isCheckingTicket = false + public var toastMessage: String? public init() {} } @@ -23,6 +27,7 @@ public struct ClassIntroFeature { public enum Action: ViewAction { case view(View) case customAlert(PresentationAction) + case inner(InnerAction) case delegate(DelegateAction) } @@ -33,6 +38,12 @@ public struct ClassIntroFeature { case myClassesTapped case createTapped case ticketTapped + case toastShown + } + + @CasePathable + public enum InnerAction: Equatable { + case ticketResponse(Result) } @CasePathable @@ -42,8 +53,15 @@ public struct ClassIntroFeature { case myClasses case create case ticket + case registerTicket } + public enum CancelID: Hashable, Sendable { + case ticketCheck + } + + @Dependency(\.classTicketUseCase) private var ticketUseCase + public var body: some Reducer { Reduce { state, action in switch action { @@ -53,6 +71,9 @@ public struct ClassIntroFeature { case let .customAlert(alertAction): return handleCustomAlertAction(state: &state, action: alertAction) + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: return .none } @@ -71,8 +92,12 @@ extension ClassIntroFeature { action: PresentationAction ) -> Effect { switch action { - case .presented(.confirmTapped), .presented(.cancelTapped), .dismiss: - state.customAlert = nil + case .presented(.confirmTapped): + let notice = state.ticketNotice + clearTicketNotice(state: &state) + return notice == .expired ? .send(.delegate(.registerTicket)) : .none + case .presented(.cancelTapped), .dismiss: + clearTicketNotice(state: &state) return .none case .presented: return .none @@ -85,19 +110,81 @@ extension ClassIntroFeature { ) -> Effect { switch action { case .backTapped: - return .send(.delegate(.backToHome)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.backToHome))) case .joinTapped: - return .send(.delegate(.join)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.join))) case .myClassesTapped: - return .send(.delegate(.myClasses)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.myClasses))) case .createTapped: - return .send(.delegate(.create)) + // Android와 같이 만들기 전에 내 이용권 상태를 확인한다. 확인 중 다시 누르면 무시한다. + guard !state.isCheckingTicket else { return .none } + state.isCheckingTicket = true + return .run { [ticketUseCase] send in + let result: Result = await Result { + try await ticketUseCase.fetchMyTicket() + } + .mapError { ($0 as? ClassTicketError) ?? .network($0.localizedDescription) } + await send(.inner(.ticketResponse(result))) + } + .cancellable(id: CancelID.ticketCheck, cancelInFlight: true) case .ticketTapped: - return .send(.delegate(.ticket)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.ticket))) + + case .toastShown: + state.toastMessage = nil + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .ticketResponse(result): + guard state.isCheckingTicket else { return .none } + state.isCheckingTicket = false + switch result { + case let .success(ticket): + switch ticket.status { + case .active: + return .send(.delegate(.create)) + case .expired: + presentTicketNotice(.expired, state: &state) + } + case .failure(.notFound): + presentTicketNotice(.required, state: &state) + case .failure: + state.toastMessage = "정보를 불러오지 못했어요" + } + return .none } } + + /// 확인 중 다른 이동을 하면 늦은 응답이 화면을 쌓지 않도록 확인을 취소한다. + private func cancelTicketCheck( + state: inout State, + then effect: Effect + ) -> Effect { + guard state.isCheckingTicket else { return effect } + state.isCheckingTicket = false + return .merge(.cancel(id: CancelID.ticketCheck), effect) + } + + private func presentTicketNotice( + _ notice: ClassCreateTicketNotice, + state: inout State + ) { + state.ticketNotice = notice + state.customAlert = notice.alert + } + + private func clearTicketNotice(state: inout State) { + state.ticketNotice = nil + state.customAlert = nil + } } diff --git a/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift b/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift index d056b99a..108f68e1 100644 --- a/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift +++ b/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift @@ -42,6 +42,11 @@ public struct ClassIntroView: View { .background(backgroundImage()) .toolbar(.hidden, for: .navigationBar) .customAlert($store.scope(state: \.customAlert, action: \.customAlert)) + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } diff --git a/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift b/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift index a7819431..63e7f5ea 100644 --- a/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift +++ b/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift @@ -15,10 +15,20 @@ public struct ClassMyOpinionsFeature { @ObservableState public struct State: Equatable { + /// 레거시 리포트 경로(서버 결과가 없을 때) 코디네이터가 채우는 스냅샷. public var opinions: [ClassReportOpinion] = [] + /// 서버 경로: 클래스에서 내가 쓴 공개 의견(Android `남긴 댓글`). + public var perspectiveOpinions: [ClassReportOpinion] = [] + /// 서버 경로: 내 활동 API의 내가 남긴 댓글(Android `남긴 대댓글`). + public var replyOpinions: [ClassReportOpinion] = [] public var classID: Int? public var isLoading = false public var errorMessage: String? + public var myDisplayName: String? + public var nextCursor: String? + public var hasNext = false + public var isLoadingMore = false + public var toastMessage: String? public var comments: [ClassMyOpinionRow] { rows.filter { $0.opinion.kind == .comment } @@ -32,10 +42,18 @@ public struct ClassMyOpinionsFeature { rows.filter { $0.opinion.kind == .unspecified } } - public var isEmpty: Bool { opinions.isEmpty } + public var isEmpty: Bool { + opinions.isEmpty && perspectiveOpinions.isEmpty && replyOpinions.isEmpty + } + + public var canLoadMoreReplies: Bool { + hasNext && nextCursor != nil && !isLoadingMore + } private var rows: [ClassMyOpinionRow] { - opinions.enumerated().map { ClassMyOpinionRow(index: $0.offset, opinion: $0.element) } + (opinions + perspectiveOpinions + replyOpinions) + .enumerated() + .map { ClassMyOpinionRow(index: $0.offset, opinion: $0.element) } } public init() {} @@ -53,16 +71,22 @@ public struct ClassMyOpinionsFeature { case onAppear case retryTapped case backTapped + case repliesEndReached + case toastShown } @CasePathable public enum AsyncAction: Equatable { case fetch + case fetchReplies + case fetchReplyPage(cursor: String?) } @CasePathable public enum InnerAction: Equatable { case perspectivesResponse(Result<[ClassPerspectiveItem], ClassBattleError>) + case myDisplayNameResponse(Result) + case replyPageResponse(cursor: String?, Result) } @CasePathable @@ -72,39 +96,127 @@ public struct ClassMyOpinionsFeature { public enum CancelID: Hashable, Sendable { case fetch + case replies + case moreReplies } + static let loadFailedMessage = "정보를 불러오지 못했어요" + @Dependency(\.classBattleUseCase) private var classUseCase + @Dependency(\.classMemberUseCase) private var memberUseCase + @Dependency(\.classResultUseCase) private var resultUseCase public var body: some Reducer { Reduce { state, action in switch action { - case .view(.onAppear): - guard state.classID != nil, !state.isLoading, state.opinions.isEmpty, state.errorMessage == nil else { return .none } - return .send(.async(.fetch)) - case .view(.retryTapped): - guard state.classID != nil, !state.isLoading else { return .none } - return .send(.async(.fetch)) - case .view(.backTapped): - return .merge(.cancel(id: CancelID.fetch), .send(.delegate(.dismiss))) - case .async(.fetch): - guard let classID = state.classID else { return .none } - state.isLoading = true - state.errorMessage = nil - return .run { [useCase = classUseCase] send in - let result = await Result { try await useCase.fetchMyPerspectives(classID: classID) } - .mapError { ($0 as? ClassBattleError) ?? .network($0.localizedDescription) } - await send(.inner(.perspectivesResponse(result))) + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: + return .none + } + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.classID != nil, + !state.isLoading, + state.perspectiveOpinions.isEmpty, + state.replyOpinions.isEmpty, + state.errorMessage == nil else { return .none } + return .merge(.send(.async(.fetch)), .send(.async(.fetchReplies))) + case .retryTapped: + guard state.classID != nil, !state.isLoading else { return .none } + state.perspectiveOpinions = [] + state.replyOpinions = [] + state.nextCursor = nil + state.hasNext = false + state.isLoadingMore = false + return .concatenate( + .cancel(id: CancelID.replies), + .cancel(id: CancelID.moreReplies), + .merge(.send(.async(.fetch)), .send(.async(.fetchReplies))) + ) + case .backTapped: + return .merge( + .cancel(id: CancelID.fetch), + .cancel(id: CancelID.replies), + .cancel(id: CancelID.moreReplies), + .send(.delegate(.dismiss)) + ) + case .repliesEndReached: + guard state.canLoadMoreReplies else { return .none } + return .send(.async(.fetchReplyPage(cursor: state.nextCursor))) + case .toastShown: + state.toastMessage = nil + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetch: + guard let classID = state.classID else { return .none } + state.isLoading = true + state.errorMessage = nil + return .run { [useCase = classUseCase] send in + let result = await Result { try await useCase.fetchMyPerspectives(classID: classID) } + .mapError { ($0 as? ClassBattleError) ?? .network($0.localizedDescription) } + await send(.inner(.perspectivesResponse(result))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + case .fetchReplies: + guard let classID = state.classID else { return .none } + return .run { [memberUseCase] send in + let result = await Result { + let list = try await memberUseCase.fetchMembers(classID: classID) + return list.members.first { $0.id == list.currentMemberID }?.name ?? "" + } + .mapError(ClassError.from) + await send(.inner(.myDisplayNameResponse(result))) + } + .cancellable(id: CancelID.replies, cancelInFlight: true) + case let .fetchReplyPage(cursor): + guard let classID = state.classID else { return .none } + if cursor != nil { state.isLoadingMore = true } + return .run { [resultUseCase] send in + let result = await Result { + try await resultUseCase.fetchMyActivity(roomID: classID, cursor: cursor) } - .cancellable(id: CancelID.fetch, cancelInFlight: true) - case let .inner(.perspectivesResponse(result)): - state.isLoading = false - switch result { - case let .success(items): - state.opinions = items.map { + .mapError(ClassError.from) + await send(.inner(.replyPageResponse(cursor: cursor, result))) + } + .cancellable(id: cursor == nil ? CancelID.replies : CancelID.moreReplies, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .perspectivesResponse(result): + state.isLoading = false + switch result { + case let .success(items): + // Android `PerspectiveStatus.from`처럼 알 수 없는 상태는 공개로 보고 검수 중·거절만 뺀다. + state.perspectiveOpinions = items + .filter { !["PENDING", "REJECTED"].contains($0.status?.uppercased() ?? "") } + .map { ClassReportOpinion( id: String($0.id), - kind: .unspecified, + kind: .comment, body: $0.content, stance: $0.option.stance ?? $0.option.title, likeCount: $0.likeCount, @@ -112,13 +224,44 @@ public struct ClassMyOpinionsFeature { authorName: $0.author.nickname ) } - case let .failure(error): - state.errorMessage = error.message - } - return .none - case .delegate: + case let .failure(error): + state.errorMessage = error.message + } + return .none + case let .myDisplayNameResponse(result): + switch result { + case let .success(name): + state.myDisplayName = name + return .send(.async(.fetchReplyPage(cursor: nil))) + case .failure: + state.toastMessage = Self.loadFailedMessage return .none } + case let .replyPageResponse(cursor, result): + state.isLoadingMore = false + switch result { + case let .success(activity): + let name = state.myDisplayName.flatMap { $0.isEmpty ? nil : $0 } ?? "나" + let page = activity.comments.map { comment in + ClassReportOpinion( + id: "reply-\(comment.id)", + kind: .reply, + body: comment.content, + likeCount: comment.likeCount, + authorName: name, + parent: ClassReportReplyContext(authorName: nil, stance: nil, body: comment.perspectiveTitle) + ) + } + // 첫 페이지는 교체하고 다음 페이지만 이어 붙여 다시 나타날 때 중복되지 않게 한다. + state.replyOpinions = cursor == nil ? page : state.replyOpinions + page + state.nextCursor = activity.nextCursor + state.hasNext = activity.hasNext + case .failure: + state.toastMessage = Self.loadFailedMessage + // Android `RequestFailed`처럼 다음 페이지 실패 시 더 불러오기를 멈춰 무한 로딩·반복 토스트를 막는다. + if cursor != nil { state.hasNext = false } + } + return .none } } } diff --git a/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift b/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift index c8ba407f..ee1163ea 100644 --- a/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift +++ b/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift @@ -28,6 +28,11 @@ public struct ClassMyOpinionsView: View { .screenBackground() .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } + .onChange(of: store.toastMessage) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } @@ -77,13 +82,16 @@ private extension ClassMyOpinionsView { } else if store.isEmpty { emptyState } else { - VStack(alignment: .leading, spacing: 20) { + LazyVStack(alignment: .leading, spacing: 20) { if !store.comments.isEmpty { opinionSection("남긴 댓글", rows: store.comments, headingGap: 16) } if !store.replies.isEmpty { opinionSection("남긴 대댓글", rows: store.replies, headingGap: 12) } + if store.canLoadMoreReplies { + loadMoreRepliesIndicator + } if !store.unspecified.isEmpty { opinionSection("남긴 의견", rows: store.unspecified, headingGap: 12) } @@ -96,6 +104,13 @@ private extension ClassMyOpinionsView { .scrollIndicators(.hidden) } + /// 목록 끝에 닿으면 다음 대댓글 페이지를 불러온다(Android `CommentsEndReach`). + var loadMoreRepliesIndicator: some View { + ProgressView() + .frame(maxWidth: .infinity) + .onAppear { send(.repliesEndReached) } + } + var emptyState: some View { Text("아직 남긴 의견이 없어요.") .pretendardFont(.regular13) diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift index 107eb195..5e00330f 100644 --- a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift @@ -59,6 +59,8 @@ public struct ClassOwnerDashboardFeature { public var feedbackError: ClassAIFeedbackError? public var insightRequestID: UUID? public var feedbackRequestID: UUID? + public var receivedLikeCount: Int? + public var myActivityRequestID: UUID? public var isGeneratingInsight = false public var isGeneratingFeedback = false public var insightUnknownRetryCount = 0 @@ -183,6 +185,10 @@ public struct ClassOwnerDashboardFeature { return .unavailable } + public var personalReceivedLikeCountText: String { + receivedLikeCount.map(String.init) ?? "—" + } + public var myOpinionCount: Int { if classResult != nil { return generatedFeedback?.perspectiveCount ?? 0 } return myOpinions.count @@ -193,7 +199,9 @@ public struct ClassOwnerDashboardFeature { } public var myOpinionsTitle: String { - "내가 남긴 의견 \(myOpinionCount)개 보기" + // 리포트 화면과 같이 개인 피드백을 받지 못하면 개수를 모르므로 표시하지 않는다. + if classResult != nil, generatedFeedback == nil { return "내가 남긴 의견 보기" } + return "내가 남긴 의견 \(myOpinionCount)개 보기" } public var canOpenMyOpinions: Bool { @@ -337,6 +345,7 @@ public struct ClassOwnerDashboardFeature { case fetchDashboard case generateInsight case generatePersonal + case fetchMyActivity } @CasePathable @@ -357,12 +366,14 @@ public struct ClassOwnerDashboardFeature { Result, wasUnknown: Bool ) + case myActivityResponse(instanceID: UUID, requestID: UUID, roomID: Int, Result) } public enum CancelID: Hashable, Sendable { case load(UUID) case generate(UUID) case generatePersonal(UUID) + case myActivity(UUID) } @CasePathable @@ -423,12 +434,14 @@ extension ClassOwnerDashboardFeature { state.requestID = nil state.insightRequestID = nil state.feedbackRequestID = nil + state.myActivityRequestID = nil state.isGeneratingInsight = false state.isGeneratingFeedback = false return .merge( .cancel(id: CancelID.load(state.instanceID)), .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.generatePersonal(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), .send(.delegate(.dismiss)) ) case .managementTapped: @@ -491,6 +504,8 @@ extension ClassOwnerDashboardFeature { state.isGeneratingFeedback = false state.insightUnknownRetryCount = 0 state.feedbackUnknownRetryCount = 0 + state.receivedLikeCount = nil + state.myActivityRequestID = nil let requestID = uuid() state.requestID = requestID state.sourceViewState = .loading @@ -514,8 +529,25 @@ extension ClassOwnerDashboardFeature { return .concatenate( .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.generatePersonal(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), fetch ) + case .fetchMyActivity: + let requestID = uuid() + state.myActivityRequestID = requestID + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + let result = await Result { + try await classUseCase.fetchMyActivity(roomID: roomID, cursor: nil) + } + .mapError(ClassError.from) + await send(.inner(.myActivityResponse( + instanceID: instanceID, + requestID: requestID, + roomID: roomID, + result + ))) + } + .cancellable(id: CancelID.myActivity(state.instanceID), cancelInFlight: true) case .generateInsight: let retryServerGeneration = state.insightError == .serverRetryAvailable let requestID = uuid() @@ -594,12 +626,16 @@ extension ClassOwnerDashboardFeature { case (true, true): return .merge( .send(.async(.generateInsight)), - .send(.async(.generatePersonal)) + .send(.async(.generatePersonal)), + .send(.async(.fetchMyActivity)) ) case (true, false): return .send(.async(.generateInsight)) case (false, true): - return .send(.async(.generatePersonal)) + return .merge( + .send(.async(.generatePersonal)), + .send(.async(.fetchMyActivity)) + ) case (false, false): return .none } @@ -609,6 +645,13 @@ extension ClassOwnerDashboardFeature { state.sourceViewState = .failed } return .none + case let .myActivityResponse(instanceID, requestID, roomID, result): + guard state.instanceID == instanceID, + state.myActivityRequestID == requestID, + state.room.id == roomID else { return .none } + state.myActivityRequestID = nil + state.receivedLikeCount = try? result.get().receivedLikeCount + return .none case let .dashboardResponse(instanceID, requestID, roomID, result): guard state.instanceID == instanceID, state.requestID == requestID, diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift index 7abd2546..9b0830fc 100644 --- a/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift @@ -553,9 +553,11 @@ private extension ClassOwnerDashboardView { reportChoice("사후 선택", value: feedback.postOption?.title ?? "선택 없음") } HStack(spacing: 0) { - reportActivity("\(feedback.perspectiveCount)", label: "의견") + reportActivity("\(feedback.perspectiveCount)", label: "댓글") Rectangle().fill(.beige600).frame(width: 1, height: 36) - reportActivity("\(feedback.commentCount)", label: "댓글") + reportActivity("\(feedback.commentCount)", label: "대댓글") + Rectangle().fill(.beige600).frame(width: 1, height: 36) + reportActivity(store.personalReceivedLikeCountText, label: "받은 추천") } } .padding(16) diff --git a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift index 7c757696..d0886092 100644 --- a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift +++ b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift @@ -377,10 +377,10 @@ extension ClassRecommendFeature { current = try await useCase.fetchTopicDraft(id: draft.id) } guard current.suggestStatus != "GENERATING" else { - throw ClassError.unknown("AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요.") + throw ClassError.userMessage("AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요.") } guard current.suggestStatus == "SUCCEEDED" else { - throw ClassError.unknown(current.clarificationMessage ?? "AI 주제를 만들지 못했어요. 조건을 확인해 주세요.") + throw ClassError.userMessage(current.clarificationMessage ?? "AI 주제를 만들지 못했어요. 조건을 확인해 주세요.") } return current } diff --git a/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift b/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift index 33188428..0182c49e 100644 --- a/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift +++ b/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift @@ -4,6 +4,8 @@ public struct ClassReportHighlightRow: Equatable, Identifiable { public let text: String public let isLast: Bool + public var quotedText: String { "“\(text)”" } + public init( id: Int, title: String, diff --git a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift index ba243350..a6ed1dbf 100644 --- a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift +++ b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift @@ -33,6 +33,8 @@ public struct ClassReportFeature { public var insight: ClassAIInsight? public var insightError: ClassAIFeedbackError? public var insightRequestID: UUID? + public var receivedLikeCount: Int? + public var myActivityRequestID: UUID? public var isGeneratingFeedback = false public var unknownRetryCount = 0 @@ -90,13 +92,17 @@ public struct ClassReportFeature { } public var serverParticipationRate: String { - guard let rate = classResult?.stats.participationRate else { return "" } - return rate.formatted(.number.precision(.fractionLength(0...1))) + "%" + guard let stats = classResult?.stats else { return "" } + guard stats.activeMemberCount > 0 else { return "아직 참여 없음" } + return (Double(stats.completedCount) / Double(stats.activeMemberCount) * 100) + .formatted(.number.precision(.fractionLength(0...1))) + "%" } public var serverCommentRate: String { - guard let rate = classResult?.stats.commentRate else { return "" } - return rate.formatted(.number.precision(.fractionLength(0...1))) + "%" + guard let stats = classResult?.stats else { return "" } + guard stats.activeMemberCount > 0 else { return "아직 참여 없음" } + return (Double(stats.commentWriterCount) / Double(stats.activeMemberCount) * 100) + .formatted(.number.precision(.fractionLength(0...1))) + "%" } public var serverParticipationCount: String { @@ -110,11 +116,13 @@ public struct ClassReportFeature { } public var serverParticipationProgress: Double { - min(max((classResult?.stats.participationRate ?? 0) / 100, 0), 1) + guard let stats = classResult?.stats, stats.activeMemberCount > 0 else { return 0 } + return min(max(Double(stats.completedCount) / Double(stats.activeMemberCount), 0), 1) } public var serverCommentProgress: Double { - min(max((classResult?.stats.commentRate ?? 0) / 100, 0), 1) + guard let stats = classResult?.stats, stats.activeMemberCount > 0 else { return 0 } + return min(max(Double(stats.commentWriterCount) / Double(stats.activeMemberCount), 0), 1) } public var serverOptionCounts: [Int: String] { @@ -124,12 +132,18 @@ public struct ClassReportFeature { } } + public var serverDistributionTotalCount: Int { + classResult?.stats.options.reduce(0) { $0 + max($1.postVoteCount, 0) } ?? 0 + } + public var serverDistributionTotalText: String { - "\(classResult?.stats.postVoteCount ?? 0)명" + "\(serverDistributionTotalCount)명" } public var serverDistributionProgress: Double { - min(max((classResult?.stats.options.first?.postVoteRatio ?? 0) / 100, 0), 1) + guard serverDistributionTotalCount > 0 else { return 0 } + return Double(max(classResult?.stats.options.first?.postVoteCount ?? 0, 0)) + / Double(serverDistributionTotalCount) } public var serverDistributionOptionCountTexts: [Int: String] { @@ -141,8 +155,19 @@ public struct ClassReportFeature { public var serverDistributionOptionRatioTexts: [Int: String] { guard let options = classResult?.stats.options else { return [:] } - return options.reduce(into: [:]) { values, option in - values[option.id] = option.postVoteRatio.formatted(.number.precision(.fractionLength(0...1))) + "%" + guard serverDistributionTotalCount > 0 else { + return options.reduce(into: [:]) { $0[$1.id] = "아직 참여 없음" } + } + var remainingTenths = 1000 + return options.enumerated().reduce(into: [:]) { values, pair in + let (index, option) = pair + let tenths = index == options.count - 1 + ? remainingTenths + : Int((Double(max(option.postVoteCount, 0)) * 1000 + / Double(serverDistributionTotalCount)).rounded()) + remainingTenths -= tenths + values[option.id] = (Double(tenths) / 10) + .formatted(.number.precision(.fractionLength(0...1))) + "%" } } @@ -218,6 +243,11 @@ public struct ClassReportFeature { feedback.map { String($0.commentCount) } ?? "—" } + /// Android와 같이 받은 추천 수를 모르면 0 대신 "—"로 표시한다. + public var personalReceivedLikeCountText: String { + receivedLikeCount.map(String.init) ?? "—" + } + public var myOpinionsButtonTitle: String { if classResult != nil, feedback == nil { return "내가 남긴 의견 보기" } return "내가 남긴 의견 \(myOpinionCount)개 보기" @@ -262,6 +292,7 @@ public struct ClassReportFeature { case fetchVoteHistory case generateFeedback case fetchInsight + case fetchMyActivity } @CasePathable @@ -277,6 +308,7 @@ public struct ClassReportFeature { wasUnknown: Bool ) case insightResponse(instanceID: UUID, requestID: UUID, roomID: Int, Result) + case myActivityResponse(instanceID: UUID, requestID: UUID, roomID: Int, Result) } @CasePathable @@ -290,6 +322,7 @@ public struct ClassReportFeature { case voteHistory(UUID) case generate(UUID) case insight(UUID) + case myActivity(UUID) } @Dependency(\.classResultUseCase) private var classUseCase @@ -337,12 +370,14 @@ extension ClassReportFeature { state.voteHistoryRequestID = nil state.feedbackRequestID = nil state.insightRequestID = nil + state.myActivityRequestID = nil state.isGeneratingFeedback = false return .merge( .cancel(id: CancelID.load(state.instanceID)), .cancel(id: CancelID.voteHistory(state.instanceID)), .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.insight(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), .send(.delegate(.dismiss)) ) case let .tabSelected(tab): @@ -375,6 +410,8 @@ extension ClassReportFeature { state.feedbackError = nil state.isGeneratingFeedback = false state.unknownRetryCount = 0 + state.receivedLikeCount = nil + state.myActivityRequestID = nil let requestID = uuid() state.requestID = requestID state.sourceViewState = .loading @@ -399,8 +436,25 @@ extension ClassReportFeature { .cancel(id: CancelID.voteHistory(state.instanceID)), .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.insight(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), fetch ) + case .fetchMyActivity: + let requestID = uuid() + state.myActivityRequestID = requestID + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + let result = await Result { + try await classUseCase.fetchMyActivity(roomID: roomID, cursor: nil) + } + .mapError(ClassError.from) + await send(.inner(.myActivityResponse( + instanceID: instanceID, + requestID: requestID, + roomID: roomID, + result + ))) + } + .cancellable(id: CancelID.myActivity(state.instanceID), cancelInFlight: true) case .fetchVoteHistory: let requestID = uuid() state.voteHistoryRequestID = requestID @@ -490,6 +544,7 @@ extension ClassReportFeature { } if classResult.myReportState != nil { effects.append(.send(.async(.generateFeedback))) + effects.append(.send(.async(.fetchMyActivity))) } if state.room.role != .member, (classResult.insightState == "SUCCEEDED" || classResult.insightState == "LIMITED") { @@ -503,6 +558,14 @@ extension ClassReportFeature { return .none } + case let .myActivityResponse(instanceID, requestID, roomID, result): + guard state.instanceID == instanceID, + state.myActivityRequestID == requestID, + state.room.id == roomID else { return .none } + state.myActivityRequestID = nil + state.receivedLikeCount = try? result.get().receivedLikeCount + return .none + case let .voteHistoryResponse(instanceID, requestID, roomID, result): guard state.instanceID == instanceID, state.voteHistoryRequestID == requestID, diff --git a/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift index 018b02cc..995de24f 100644 --- a/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift +++ b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift @@ -130,9 +130,11 @@ private extension ClassReportView { .padding(.vertical, 12) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) HStack(spacing: 0) { - activityCount(store.personalPerspectiveCountText, label: "작성한 의견") + activityCount(store.personalPerspectiveCountText, label: "댓글") Rectangle().fill(.beige600).frame(width: 1, height: 38) - activityCount(store.personalCommentCountText, label: "작성한 댓글") + activityCount(store.personalCommentCountText, label: "대댓글") + Rectangle().fill(.beige600).frame(width: 1, height: 38) + activityCount(store.personalReceivedLikeCountText, label: "받은 추천") } } else if store.voteHistoryRequestID != nil { SkeletonView(.round(cornerRadius: 2)).frame(height: 48) @@ -149,7 +151,7 @@ private extension ClassReportView { func serverResultPages(_ result: ClassResult) -> some View { let highlightRows = store.highlightRows ScrollView { - VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 24) { if let insight = store.insight { insightCard(insight) } else if result.insightState != nil { @@ -157,18 +159,20 @@ private extension ClassReportView { } VStack(alignment: .leading, spacing: 12) { - sectionHeading("클래스 참여 현황") - reportCard { + sectionHeading("클래스 참여 현황", textStyle: .headingSmall) + reportCard(padding: 17, horizontalPadding: 17, spacing: 16) { participationBar( title: "배틀 참여율", rate: store.serverParticipationRate, + caption: "참여한 멤버", detail: store.serverParticipationCount, progress: store.serverParticipationProgress, color: .primary500 ) participationBar( - title: "댓글 작성률", + title: "댓글 작성율", rate: store.serverCommentRate, + caption: "댓글을 남긴 멤버", detail: store.serverCommentCount, progress: store.serverCommentProgress, color: .secondary500 @@ -177,15 +181,15 @@ private extension ClassReportView { } VStack(alignment: .leading, spacing: 12) { - sectionHeading("입장 분포") - reportCard { + sectionHeading("입장 분포", textStyle: .headingSmall) + reportCard(padding: 17, horizontalPadding: 17) { stanceDistribution(result.stats.options) } } if !highlightRows.isEmpty { VStack(alignment: .leading, spacing: 12) { - sectionHeading("주요 의견") + sectionHeading("주요 의견", textStyle: .headingSmall) reportCard(padding: 0, horizontalPadding: 0, spacing: 0) { ForEach(highlightRows) { row in highlightRow(row) @@ -199,7 +203,7 @@ private extension ClassReportView { if result.myReportState != nil { VStack(alignment: .leading, spacing: 12) { - sectionHeading("내 AI 피드백") + sectionHeading("내 AI 피드백", textStyle: .headingSmall) reportCard { if case let .ready(feedback) = store.feedbackViewState { Text(feedback.summaryTitle) @@ -234,23 +238,23 @@ private extension ClassReportView { @ViewBuilder func insightCard(_ insight: ClassAIInsight) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { ClassAIInsightHeading( title: "AI 인사이트", - titleFont: .semiBold12, - titleLineHeight: 17, + titleFont: .bold11, + titleLineHeight: 15, iconSize: 20, spacing: 6 ) Text(insight.text) - .pretendardFont(.regular13) + .pretendardFont(.bodySmall, lineHeightMultiple: 1.4) .foregroundStyle(.primary500) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(.secondary50) - .overlay(Rectangle().stroke(.primary100, lineWidth: 1)) + .padding(13) + .background(.secondary50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(.primary100, lineWidth: 1)) } @ViewBuilder @@ -283,70 +287,84 @@ private extension ClassReportView { func participationBar( title: String, rate: String, + caption: String, detail: String, progress: Double, color: Color ) -> some View { - VStack(alignment: .leading, spacing: 5) { + VStack(alignment: .leading, spacing: 0) { HStack { Text(title) .pretendardFont(.semiBold12) Spacer() Text(rate) - .pretendardFont(.semiBold12) + .pretendardFont(.headingSmall) } + .frame(height: 20) .foregroundStyle(.gray800) - Text(detail) - .pretendardFont(.medium10) - .foregroundStyle(.gray300) + HStack { + Text(caption) + Spacer() + Text(detail) + } + .pretendardFont(.regular11) + .foregroundStyle(.gray300) + .frame(height: 15) + .padding(.top, 6) GeometryReader { geometry in ZStack(alignment: .leading) { Capsule().fill(.beige500) Capsule().fill(color).frame(width: geometry.size.width * progress) } } - .frame(height: 5) + .frame(height: 6) + .padding(.top, 4) } - .padding(.vertical, 2) } @ViewBuilder func stanceDistribution(_ options: [ClassResultOption]) -> some View { HStack(spacing: 24) { ZStack { - Circle().stroke(.secondary500, lineWidth: 12) Circle() + .inset(by: 6) + .stroke( + store.serverDistributionTotalCount == 0 ? Color.beige500 : Color.secondary500, + lineWidth: 12 + ) + Circle() + .inset(by: 6) .trim(from: 0, to: store.serverDistributionProgress) .stroke(.primary500, style: StrokeStyle(lineWidth: 12, lineCap: .butt)) .rotationEffect(.degrees(-90)) - VStack(spacing: 1) { + VStack(spacing: 0) { Text(store.serverDistributionTotalText) - .pretendardFont(.semiBold15) + .pretendardFont(.labelLarge, lineHeightMultiple: 1.5) .foregroundStyle(.gray800) Text("총 참여") - .pretendardFont(.medium10) + .pretendardFont(.medium10, lineHeightMultiple: 1.4) .foregroundStyle(.gray300) } } .frame(width: 100, height: 100) - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 12) { ForEach(Array(options.prefix(2).enumerated()), id: \.element.id) { index, option in - HStack(spacing: 6) { + HStack(spacing: 8) { Circle() .fill(index == 0 ? Color.primary500 : Color.secondary500) - .frame(width: 7, height: 7) - VStack(alignment: .leading, spacing: 2) { + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 0) { Text(option.title) - .pretendardFont(.semiBold11) + .pretendardFont(.semiBold12, lineHeightMultiple: 1.4) .foregroundStyle(.gray800) .lineLimit(1) Text(store.serverDistributionOptionCountTexts[option.id] ?? "") - .pretendardFont(.medium10) + .pretendardFont(.medium11, lineHeightMultiple: 1.4) .foregroundStyle(.gray300) } Spacer(minLength: 2) Text(store.serverDistributionOptionRatioTexts[option.id] ?? "") - .pretendardFont(.semiBold11) + .pretendardFont(family: .Bold, size: 12) .foregroundStyle(.gray800) } } @@ -357,17 +375,22 @@ private extension ClassReportView { @ViewBuilder func highlightRow(_ row: ClassReportHighlightRow) -> some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text(row.title) - .pretendardFont(.semiBold11) - .foregroundStyle(.primary500) - Text(row.text) - .pretendardFont(.regular13) - .foregroundStyle(.gray700) + .pretendardFont(.semiBold12) + .foregroundStyle(.badgeFilledText) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.badgeFilledBackground, in: RoundedRectangle(cornerRadius: 2)) + Text(row.quotedText) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray400) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 2) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + .padding(16) } @ViewBuilder @@ -747,11 +770,14 @@ private extension ClassReportView { } } - func sectionHeading(_ title: String) -> some View { + func sectionHeading( + _ title: String, + textStyle: CustomSizeFont = .semiBold13 + ) -> some View { Text(title) - .pretendardFont(.semiBold13) + .pretendardFont(textStyle) .foregroundStyle(.gray800) - .frame(minHeight: 18) + .frame(minHeight: (textStyle.size * 1.4).rounded()) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift b/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift index a11a6f2d..6447e056 100644 --- a/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift +++ b/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift @@ -7,6 +7,7 @@ import ClassDomainInterface import ComposableArchitecture +import CryptoKit import Foundation import PickeCoreUtility import PickeSharedUI @@ -95,7 +96,8 @@ public struct ClassSettingFeature { battleId: battle.id, allowsAnonymousOpinion: false, requiresComment: requiresComment, - ownerParticipates: ownerParticipates + ownerParticipates: ownerParticipates, + requestKey: creationRequestKey(subject: "battle:\(battle.id)") ) } guard let topicVersionID, aiQuestion != nil else { return nil } @@ -105,7 +107,8 @@ public struct ClassSettingFeature { topicVersionId: topicVersionID, allowsAnonymousOpinion: false, requiresComment: requiresComment, - ownerParticipates: ownerParticipates + ownerParticipates: ownerParticipates, + requestKey: creationRequestKey(subject: "topic:\(topicVersionID)") ) } @@ -138,10 +141,24 @@ public struct ClassSettingFeature { topicVersionId: topicVersionID, allowsAnonymousOpinion: false, requiresComment: requiresComment, - ownerParticipates: ownerParticipates + ownerParticipates: ownerParticipates, + requestKey: creationRequestKey(subject: "topic:\(topicVersionID)") ) } + private func creationRequestKey(subject: String) -> String { + let fields = [ + instanceID.uuidString, + Data(trimmedName.utf8).base64EncodedString(), + subject, + String((isDeadlineEnabled ? deadline : Date.distantFuture).timeIntervalSinceReferenceDate.bitPattern), + String(requiresComment), + String(ownerParticipates), + ] + let digest = SHA256.hash(data: Data(fields.joined(separator: "|").utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketRegisterNotice.swift b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketRegisterNotice.swift new file mode 100644 index 00000000..3dfb3128 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketRegisterNotice.swift @@ -0,0 +1,40 @@ +// +// ClassTicketRegisterNotice.swift +// Class +// +// Created by Picke on 11/4/26. +// + +import ClassDomainInterface +import PickeSharedUI + +/// 이용권 등록 화면 안내(Android `TicketRegisterDialog`). +public enum ClassTicketRegisterNotice: Equatable, Sendable { + /// 다른 계정에 등록된 코드. 뒤로가기로 화면을 닫는다. + case alreadyRegistered + case invalidCode + case registrationExpired + case alreadyActive + case registerFailed + + /// Android `toRegisterDialog`와 같은 매핑. 사용 중 이용권(`TICKET_409_ACTIVE`)만 iOS에서 따로 안내한다. + public init(error: ClassTicketError) { + switch error { + case .claimed: self = .alreadyRegistered + case .invalidCode, .revoked: self = .invalidCode + case .registrationExpired: self = .registrationExpired + case .activeTicket: self = .alreadyActive + case .notFound, .invalidResponse, .network: self = .registerFailed + } + } + + public var alert: CustomAlertState { + switch self { + case .alreadyRegistered: return .ticketAlreadyRegistered() + case .invalidCode: return .ticketInvalidCode() + case .registrationExpired: return .ticketRegistrationExpired() + case .alreadyActive: return .ticketAlreadyActive() + case .registerFailed: return .ticketRegisterFailed() + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift index 86650ddf..a3b71e73 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift @@ -7,6 +7,7 @@ import ClassDomainInterface import ComposableArchitecture import Foundation import PickeCoreUtility +import PickeSharedUI @Reducer public struct ClassTicketFeature { @@ -20,19 +21,31 @@ public struct ClassTicketFeature { public var code = "" public var isChecking = false public var isLoadingExistingTicket = false - public var errorMessage: String? public var expandedFAQ: Int? public var preview: ClassTicketPreview? public var ticket: ClassTicket? public var registrationStartDate: Date? public var expirationDate: Date? + @Presents public var customAlert: CustomAlertState? + public var registerNotice: ClassTicketRegisterNotice? + public var toastMessage: String? + public var closesAfterToast = false public init(instanceID: UUID = UUID()) { self.instanceID = instanceID } + /// Android와 같이 대문자로 바꾸고 영문·숫자·`-`만 남긴다(서버도 공백·하이픈·대소문자를 무시). + public var normalizedCode: String { + // 글자 묶음이 아니라 유니코드 단위로 걸러 ASCII만 남긴다(예: "1️⃣", "A̲"의 결합 문자 제거). + let scalars = code.uppercased().unicodeScalars.filter { scalar in + ("A"..."Z").contains(scalar) || ("0"..."9").contains(scalar) || scalar == "-" + } + return String(scalars) + } + public var canCheckCode: Bool { - !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isChecking && !isLoadingExistingTicket + !normalizedCode.isEmpty && !isChecking && !isLoadingExistingTicket } public var navigationTitle: String { @@ -47,8 +60,9 @@ public struct ClassTicketFeature { capacity.map { "클래스 \($0)인 이용권" } ?? "클래스 이용권" } + /// 개요는 사용 중(ACTIVE) 이용권만 보여 준다(만료면 코드 입력). public var overviewTitle: String { - ticket?.status == .expired ? "만료된 이용권" : "사용 중인 이용권" + "사용 중인 이용권" } public var capacityDetail: String { @@ -57,7 +71,7 @@ public struct ClassTicketFeature { public var ticketPeriodText: String { if phase == .preview, let preview { - return "등록 후 \(preview.usageMonths)개월" + return "등록일부터 \(preview.usageMonths)개월" } return periodText } @@ -84,9 +98,13 @@ public struct ClassTicketFeature { public enum Action: ViewAction, BindableAction { case binding(BindingAction) case view(View) + case customAlert(PresentationAction) case checkResponse(Result) case registrationResponse(Result) - case myTicketResponse(Result) + /// 화면 진입 시 내 이용권 조회 결과. + case existingTicketResponse(Result) + /// 내가 등록한 코드를 다시 입력했을 때 내 이용권 조회 결과. + case registeredTicketResponse(Result) case delegate(DelegateAction) } @@ -98,6 +116,7 @@ public struct ClassTicketFeature { case confirmTapped case addRegistrationTapped case faqTapped(Int) + case toastShown } @CasePathable @@ -106,6 +125,8 @@ public struct ClassTicketFeature { case registrationConfirmed(startDate: Date, endDate: Date) case addRegistrationRequested case registered(Date, Date) + /// 내가 등록한 사용 중 이용권을 다시 입력함. 코디네이터가 기존 개요로 돌아가거나 이 화면을 개요로 바꾼다. + case existingTicketFound(ClassTicket) } public enum CancelID: Hashable, Sendable { @@ -120,8 +141,12 @@ public struct ClassTicketFeature { Reduce { state, action in switch action { case .binding(\.code): - state.errorMessage = nil + let wasLookingUpRegisteredTicket = state.preview != nil && state.isLoadingExistingTicket state.preview = nil + if wasLookingUpRegisteredTicket { + state.isLoadingExistingTicket = false + return .cancel(id: CancelID.existingTicket(state.instanceID)) + } guard state.isChecking else { return .none } state.isChecking = false return .cancel(id: CancelID.check(state.instanceID)) @@ -138,24 +163,23 @@ public struct ClassTicketFeature { else { return .none } state.shouldCheckExistingTicket = false state.isLoadingExistingTicket = true - return .run { [ticketUseCase] send in - let result: Result = await Result { - try await ticketUseCase.fetchMyTicket() - } - .mapError(Self.mapError) - await send(.myTicketResponse(result)) - } - .cancellable(id: CancelID.existingTicket(state.instanceID), cancelInFlight: true) + return fetchMyTicket(state: state) { .existingTicketResponse($0) } case .view(.backTapped): + // Android와 같이 확인 단계에서는 코드 입력으로 돌아간다. 등록 요청 중에는 무시한다. + if state.phase == .preview { + guard !state.isChecking else { return .none } + state.phase = .entry + state.preview = nil + return .none + } return .send(.delegate(.dismiss)) case .view(.confirmTapped): if state.phase == .preview { guard state.canRegister else { return .none } state.isChecking = true - state.errorMessage = nil - let submittedCode = state.code.trimmingCharacters(in: .whitespacesAndNewlines) + let submittedCode = state.normalizedCode return .run { [ticketUseCase] send in let result: Result = await Result { try await ticketUseCase.registerTicket(code: submittedCode) @@ -186,8 +210,7 @@ public struct ClassTicketFeature { case .view(.checkTapped): guard state.phase == .entry, state.canCheckCode else { return .none } state.isChecking = true - state.errorMessage = nil - let submittedCode = state.code.trimmingCharacters(in: .whitespacesAndNewlines) + let submittedCode = state.normalizedCode return .run { [ticketUseCase] send in let result: Result = await Result { try await ticketUseCase.previewTicket(code: submittedCode) @@ -197,6 +220,25 @@ public struct ClassTicketFeature { } .cancellable(id: CancelID.check(state.instanceID), cancelInFlight: true) + case .view(.toastShown): + state.toastMessage = nil + // 토스트를 띄운 뒤에 닫아야 사라지는 화면에서도 안내가 보인다. + guard state.closesAfterToast else { return .none } + state.closesAfterToast = false + return .send(.delegate(.dismiss)) + + case .customAlert(.presented(.confirmTapped)): + let notice = state.registerNotice + clearRegisterNotice(state: &state) + return notice == .alreadyRegistered ? .send(.delegate(.dismiss)) : .none + + case .customAlert(.presented(.cancelTapped)), .customAlert(.dismiss): + clearRegisterNotice(state: &state) + return .none + + case .customAlert: + return .none + case let .checkResponse(result): guard state.phase == .entry, state.isChecking else { return .none } state.isChecking = false @@ -204,19 +246,13 @@ public struct ClassTicketFeature { case let .success(preview): state.preview = preview if preview.registeredByMe { + // Android와 같이 내 이용권 상태를 확인해 사용 중이면 이용권 화면, 아니면 만료 안내. state.isLoadingExistingTicket = true - return .run { [ticketUseCase] send in - let result: Result = await Result { - try await ticketUseCase.fetchMyTicket() - } - .mapError(Self.mapError) - await send(.myTicketResponse(result)) - } - .cancellable(id: CancelID.existingTicket(state.instanceID), cancelInFlight: true) + return fetchMyTicket(state: state) { .registeredTicketResponse($0) } } state.phase = .preview case let .failure(error): - state.errorMessage = error.message + presentRegisterNotice(.init(error: error), state: &state) } return .none @@ -231,23 +267,39 @@ public struct ClassTicketFeature { state.phase = .registered return .send(.delegate(.registered(ticket.activatedAt, ticket.expiresAt))) case let .failure(error): - state.errorMessage = error.message + presentRegisterNotice(.init(error: error), state: &state) } return .none - case let .myTicketResponse(result): + case let .existingTicketResponse(result): guard state.phase == .entry, state.isLoadingExistingTicket else { return .none } state.isLoadingExistingTicket = false switch result { - case let .success(ticket): + case let .success(ticket) where ticket.status == .active: state.ticket = ticket state.registrationStartDate = ticket.activatedAt state.expirationDate = ticket.expiresAt state.phase = .overview + case .success, .failure(.notFound): + // 만료됐거나 없으면 Android와 같이 코드 입력부터 시작한다. + break + case .failure: + state.toastMessage = "정보를 불러오지 못했어요" + state.closesAfterToast = true + } + return .none + + case let .registeredTicketResponse(result): + // 코드를 고치면 preview가 비므로 늦게 온 결과는 무시된다. + guard state.phase == .entry, state.isLoadingExistingTicket, state.preview != nil else { return .none } + state.isLoadingExistingTicket = false + switch result { + case let .success(ticket) where ticket.status == .active: + return .send(.delegate(.existingTicketFound(ticket))) + case .success: + presentRegisterNotice(.registrationExpired, state: &state) case let .failure(error): - if error != .notFound { - state.errorMessage = error.message - } + presentRegisterNotice(.init(error: error), state: &state) } return .none @@ -255,6 +307,36 @@ public struct ClassTicketFeature { return .none } } + .ifLet(\.$customAlert, action: \.customAlert) { + CustomConfirmAlert() + } + } + + private func fetchMyTicket( + state: State, + response: @escaping @Sendable (Result) -> Action + ) -> Effect { + .run { [ticketUseCase] send in + let result: Result = await Result { + try await ticketUseCase.fetchMyTicket() + } + .mapError(Self.mapError) + await send(response(result)) + } + .cancellable(id: CancelID.existingTicket(state.instanceID), cancelInFlight: true) + } + + private func presentRegisterNotice( + _ notice: ClassTicketRegisterNotice, + state: inout State + ) { + state.registerNotice = notice + state.customAlert = notice.alert + } + + private func clearRegisterNotice(state: inout State) { + state.registerNotice = nil + state.customAlert = nil } private static func mapError(_ error: Error) -> ClassTicketError { diff --git a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift index c609f058..1bb03fc2 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift @@ -6,11 +6,13 @@ import ComposableArchitecture import PickeCoreUtility import PickeDesignKit +import PickeSharedUI import SwiftUI @ViewAction(for: ClassTicketFeature.self) public struct ClassTicketView: View { @Bindable public var store: StoreOf + @FocusState private var isCodeFocused: Bool public init(store: StoreOf) { self.store = store @@ -46,6 +48,16 @@ public struct ClassTicketView: View { .screenBackground(.surfaceBeigeDefault) .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } + .customAlert($store.scope(state: \.customAlert, action: \.customAlert)) + .onChange(of: store.customAlert != nil) { _, isPresented in + // 안내가 키보드에 가리지 않게 입력 포커스를 내린다. + if isPresented { isCodeFocused = false } + } + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } @@ -95,14 +107,8 @@ private extension ClassTicketView { .autocorrectionDisabled() .keyboardType(.asciiCapable) .pickeTextField(height: 44) + .focused($isCodeFocused) .accessibilityIdentifier("class-ticket-code") - - if let errorMessage = store.errorMessage { - Text(errorMessage) - .pretendardFont(.bodySmall) - .foregroundStyle(.errorDefault) - .accessibilityIdentifier("class-ticket-error") - } } } @@ -112,12 +118,12 @@ private extension ClassTicketView { faqRow( index: 0, question: "이용권 코드는 어디서 확인하나요?", - answer: "리워드로 받은 이메일에서 이용권 코드를 확인할 수 있어요." + answer: "펀딩 리워드 발송 안내에서 확인해 주세요. 코드를 받지 못했다면 해당 펀딩의 문의 경로를 이용해 주세요." ) faqRow( index: 1, question: "클래스 참여 코드와 다른가요?", - answer: "이용권 코드는 클래스를 개설할 때 사용해요. 참여 코드는 다른 사람의 클래스에 들어갈 때 사용해요." + answer: "이용권 코드는 모임을 여는 데 사용하는 리워드예요. 모임에 참가하려면 '클래스 탭' - '클래스 참여하기'에서 참여 코드를 입력해 주세요." ) } } @@ -161,28 +167,31 @@ private extension ClassTicketView { @ViewBuilder func previewContent() -> some View { VStack(alignment: .leading, spacing: 0) { - Text("이용권 내용을 확인해 주세요") + Text("이 이용권을 등록할까요?") .pretendardFont(.semiBold24) .foregroundStyle(.gray800) .padding(.bottom, 8) - Text("확인 후 등록하면 이용 기간이 바로 시작돼요") + Text("이용 조건과 주의사항을 확인해 주세요") .pretendardFont(.bodyMedium) .foregroundStyle(.gray300) .padding(.bottom, 36) ticketCard() - if let errorMessage = store.errorMessage { - Text(errorMessage) - .pretendardFont(.bodySmall) - .foregroundStyle(.errorDefault) - .padding(.top, 12) - .accessibilityIdentifier("class-ticket-error") - } + Text("등록하는 순간 이용 기간이 시작돼요\n클래스를 만들기 전에도 기간은 흘러가요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + .padding(.top, 12) - bottomSection() + Text("등록 후 마이페이지에서 이용권을 확인할 수 있어요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) .padding(.top, 40) + + bottomSection() + .padding(.top, 10) } .padding(.horizontal, 16) .padding(.top, 20) @@ -241,6 +250,9 @@ private extension ClassTicketView { @ViewBuilder func overviewBottomSection() -> some View { VStack(spacing: 10) { + Text("등록 후 마이페이지에서 이용권을 확인할 수 있어요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) Button("이용권 추가등록하기") { send(.addRegistrationTapped) } diff --git a/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift b/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift index bc1a1011..34aa6eef 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift @@ -489,6 +489,14 @@ private struct ClassVoteUseCaseStub: ClassBattleInterface { return .init(voteID: 5, status: .preVote) } + func submitMidVote( + classID _: Int, + checkpointID _: Int, + optionID _: Int + ) async throws -> ClassVoteReceipt { + throw ClassBattleError.midVoteUnavailable + } + func completeTTS(classID _: Int) async throws { throw ClassBattleError.invalidResponse } diff --git a/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift b/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift index 26ba2cb5..a9205c62 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift @@ -13,6 +13,377 @@ import Testing struct ClassBattleFlowTests { private let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000042")! + @Test + func 중간투표_선택_전에는_청취완료를_제출할_수_없다() { + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false) + state.viewState = .loaded + state.hasListened = true + state.pendingCheckpointID = 801 + #expect(!state.canSubmit) + state.pendingCheckpointID = nil + #expect(state.canSubmit) + } + + @Test + func 중간투표_후에는_사전투표를_변경할_수_없다() async { + var state = battleState(room: .mocks[0]) + state.session = session(step: .preVote, commentRequired: false, midVoteEnabled: true) + state.viewState = .loaded + state.selectedOptionID = 31 + state.voteHistory = ClassVoteHistory( + battleTitle: "토론", + preVote: .init(optionID: 31, label: "A", title: "찬성"), + postVote: nil, + status: .preVote, + opinionChanged: nil + ) + state.selectedPaths = [801: 802] + #expect(state.isPreVoteLocked) + #expect(!state.canSubmit) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + await store.send(.view(.optionTapped(32))) + #expect(store.state.selectedOptionID == 31) + } + + @Test + func 사후투표_중간투표_누락_복구는_오디오_시간_관찰을_재개한다() async { + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .postVote, commentRequired: false) + state.viewState = .loaded + state.needsMidVoteRecovery = true + state.isAudioReady = true + state.submitRequestID = requestID + state.currentTime = 15 + state.hasListened = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + await store.send(.inner(.submissionResponse(requestID, .postVote, .failure(.midVoteRequired)))) { + $0.submitRequestID = nil + $0.errorMessage = ClassBattleError.midVoteRequired.message + $0.hasListened = false + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + } + await store.finish() + #expect(await spy.timeSubscriptions == 1) + } + + @Test + func 중간투표_선택_경로의_대본만_노출한다() { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, + nodeName: "선택", + audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 1, startTimeMs: 0, speakerType: .a, speakerName: "A", text: "선택하세요.")], + interactiveOptions: [ + ScenarioInteractiveOption(label: "첫째", nextNodeId: 802, optionID: 9001), + ScenarioInteractiveOption(label: "둘째", nextNodeId: 803, optionID: 9002) + ] + ), + ScenarioNode( + nodeId: 802, nodeName: "첫째 경로", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 2, startTimeMs: 2_000, speakerType: .a, speakerName: "A", text: "첫째.")], + interactiveOptions: [] + ), + ScenarioNode( + nodeId: 803, nodeName: "둘째 경로", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 3, startTimeMs: 2_000, speakerType: .b, speakerName: "B", text: "둘째.")], + interactiveOptions: [] + ) + ] + ) + #expect(ClassBattleTranscriptLine.from(scenario: scenario).count == 1) + #expect(ClassBattleTranscriptLine.from(scenario: scenario, selectedPaths: [801: 803]) + .map(\.text) == ["선택하세요.", "둘째."]) + } + + @Test + func 이미_기록된_중간투표는_재생할_때_다시_제출하지_않는다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "선택", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 1, startTimeMs: 0, speakerType: .a, speakerName: "A", text: "선택")], + interactiveOptions: [ScenarioInteractiveOption(label: "첫째", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "첫째 경로", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 2, startTimeMs: 2_000, speakerType: .a, speakerName: "A", text: "첫째")], + interactiveOptions: [] + ) + ] + ) + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false) + state.viewState = .loaded + state.isAudioReady = true + state.isPlaying = true + state.audioDuration = 4 + state.scenario = scenario + state.currentNodeID = 801 + state.selectedPaths = [801: 802] + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + } + + await store.send(.inner(.playerTimeUpdated(requestID, 2))) { + $0.currentTime = 2 + $0.currentNodeID = 802 + $0.transcript = ClassBattleTranscriptLine.from(scenario: scenario, selectedPaths: [801: 802]) + $0.pendingSeekTime = 2 + } + #expect(store.state.pendingCheckpointID == nil) + await store.finish() + } + + @Test + func 다른_분기를_선택하면_해당_경로의_오디오를_불러온다() async throws { + let commonURL = try #require(URL(string: "https://example.com/common.mp3")) + let branchURL = try #require(URL(string: "https://example.com/path-b.mp3")) + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: ["COMMON": commonURL.absoluteString, "PATH_B": branchURL.absoluteString], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 1, startTimeMs: 0, speakerType: .a, speakerName: "A", text: "선택")], + interactiveOptions: [ScenarioInteractiveOption(label: "둘째", nextNodeId: 803, optionID: 9002)] + ), + ScenarioNode( + nodeId: 803, nodeName: "NODE_B", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 2, startTimeMs: 2_000, speakerType: .b, speakerName: "B", text: "둘째")], + interactiveOptions: [] + ) + ] + ) + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false) + state.viewState = .loaded + state.scenario = scenario + state.currentNodeID = 801 + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 803 + state.isAudioReady = true + state.loadedAudioURL = commonURL + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .constant(requestID) + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + + await store.send(.view(.confirmMidVoteTapped)) { + $0.selectedPaths = [801: 803] + $0.transcript = ClassBattleTranscriptLine.from(scenario: scenario, selectedPaths: [801: 803]) + $0.currentNodeID = 803 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 2 + $0.pendingSeekTime = 2 + $0.audioRequestID = requestID + $0.loadedAudioURL = branchURL + $0.isAudioReady = false + } + await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { + $0.audioRequestID = nil + $0.errorMessage = "오디오를 재생할 수 없어요. 다시 시도해 주세요." + } + await store.finish() + #expect(await spy.loadedURLs == [branchURL]) + } + + @Test + func 서버에서_중간투표가_비활성화되면_로컬_선택으로_재생을_계속한다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, scripts: [], + interactiveOptions: [ScenarioInteractiveOption(label: "다음", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "NEXT", audioDuration: 2, + autoNextNodeId: nil, scripts: [], interactiveOptions: [] + ) + ] + ) + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false, midVoteEnabled: true) + state.scenario = scenario + state.currentNodeID = 801 + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 802 + state.midVoteRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + } + await store.send(.inner(.midVoteResponse(requestID, 801, 9001, .failure(.midVoteUnavailable)))) { + $0.midVoteRequestID = nil + $0.selectedPaths = [801: 802] + $0.currentNodeID = 802 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + $0.midVoteDisabledLocally = true + $0.errorMessage = ClassBattleError.midVoteUnavailable.message + } + await store.finish() + } + + @Test + func 사후투표_복구_후에도_사용자가_고른_최종_선택을_제출한다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, scripts: [], + interactiveOptions: [ScenarioInteractiveOption(label: "다음", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "NEXT", audioDuration: 2, + autoNextNodeId: nil, scripts: [], interactiveOptions: [] + ) + ] + ) + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .postVote, commentRequired: false, midVoteEnabled: true) + state.viewState = .loaded + state.scenario = scenario + state.voteHistory = ClassVoteHistory( + battleTitle: "토론", + preVote: .init(optionID: 31, label: "A", title: "찬성"), + postVote: nil, + status: .postVote, + opinionChanged: nil + ) + state.selectedOptionID = 32 + state.needsMidVoteRecovery = true + state.hasListened = true + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 802 + state.midVoteRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .constant(requestID) + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: spy) + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + await store.send(.inner(.midVoteResponse(requestID, 801, 9001, .success(true)))) { + $0.midVoteRequestID = nil + $0.selectedPaths = [801: 802] + $0.currentNodeID = 802 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + } + #expect(store.state.selectedOptionID == 32) + await store.send(.view(.submitTapped)) { $0.submitRequestID = requestID } + await store.receive(\.inner, .submissionResponse(requestID, .postVote, .failure(.invalidResponse))) { + $0.submitRequestID = nil + $0.errorMessage = ClassBattleError.invalidResponse.message + } + await store.finish() + #expect(await spy.postVoteOptionIDs == [32]) + } + + @Test + func 중간투표_확인은_체크포인트와_서버_선택지_ID를_제출한다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, scripts: [], + interactiveOptions: [ScenarioInteractiveOption(label: "다음", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "NEXT", audioDuration: 2, + autoNextNodeId: nil, scripts: [], interactiveOptions: [] + ) + ] + ) + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false, midVoteEnabled: true) + state.scenario = scenario + state.currentNodeID = 801 + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 802 + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .constant(requestID) + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: spy) + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + await store.send(.view(.confirmMidVoteTapped)) { $0.midVoteRequestID = requestID } + await store.receive(\.inner, .midVoteResponse(requestID, 801, 9001, .success(true))) { + $0.midVoteRequestID = nil + $0.selectedPaths = [801: 802] + $0.currentNodeID = 802 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + } + await store.finish() + #expect(await spy.midVoteArguments.map { "\($0.0):\($0.1):\($0.2)" } == ["\(ClassRoom.mocks[0].id):801:9001"]) + } + @Test func TTS_대본은_다음_노드_시작을_넘기지_않는다() { let scenario = BattleScenario( @@ -172,6 +543,25 @@ struct ClassBattleFlowTests { #expect(state.perspectiveRows.map(\.commentCountLabel) == ["3", "2,300"]) } + @Test + func 의견_작성_시각은_N분_전_형식으로_표시한다() { + let item = ClassPerspectiveItem( + id: 1, + author: .init(userTag: "member", nickname: "참가자", characterType: nil, characterImageURL: nil), + option: .init(id: 1, label: "A", title: "찬성", stance: "찬성"), + content: "의견", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: false, + status: nil, + createdAt: Date().addingTimeInterval(-150) + ) + let row = ClassPerspectiveRow(id: 1, perspective: item, likeCountLabel: "0", commentCountLabel: "0") + + #expect(row.createdAtLabel == "2분 전") + } + @Test func 이미_제출한_의견은_빈_재조회_응답으로_다시_작성할_수_없다() async { var state = battleState(room: .mocks[0]) @@ -288,11 +678,15 @@ struct ClassBattleFlowTests { ClassBattleTranscriptLine(id: 2, startTime: 2, speakerName: "소크라테스", speakerImageURL: "https://example.com/b.png", text: "생각해요.", side: "B"), ClassBattleTranscriptLine(id: 3, startTime: 4, speakerName: "소크라테스", speakerImageURL: "https://example.com/b.png", text: "반대해요.", side: "B") ] - await store.receive(\.inner, .transcriptLoaded(requestID, expected)) { + await store.receive(\.inner, .scenarioLoaded(requestID, scenario)) { + $0.scenario = scenario + $0.currentNodeID = scenario.startNodeId $0.transcript = expected } let audioURL = try #require(URL(string: "https://example.com/class-battle.mp3")) - await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) + await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) { + $0.loadedAudioURL = audioURL + } await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { $0.audioRequestID = nil $0.errorMessage = "오디오를 재생할 수 없어요. 다시 시도해 주세요." @@ -335,7 +729,16 @@ struct ClassBattleFlowTests { $0.audioRequestID = requestID } let audioURL = try #require(URL(string: "https://example.com/class-battle.mp3")) - await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) + let scenario = try await ClassBattleFlowScenarioStub(spy: ClassBattleFlowSpy()) + .fetchScenario(battleId: session.battle.id) + await store.receive(\.inner, .scenarioLoaded(requestID, scenario)) { + $0.scenario = scenario + $0.currentNodeID = scenario.startNodeId + $0.transcript = ClassBattleTranscriptLine.from(scenario: scenario) + } + await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) { + $0.loadedAudioURL = audioURL + } await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { $0.audioRequestID = nil $0.errorMessage = "오디오를 재생할 수 없어요. 다시 시도해 주세요." @@ -344,6 +747,7 @@ struct ClassBattleFlowTests { $0.audioRequestID = requestID $0.errorMessage = nil } + await store.receive(\.inner, .scenarioLoaded(requestID, scenario)) await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { $0.audioRequestID = nil @@ -804,7 +1208,7 @@ struct ClassBattleFlowTests { status: .completed, opinionChanged: false ) - #expect(state.opinionChangeLabel == "처음 생각을 유지했어요") + #expect(state.opinionChangeLabel == "생각이 동일해요") state.voteHistory = ClassVoteHistory( battleTitle: "토론", @@ -822,7 +1226,7 @@ struct ClassBattleFlowTests { status: .completed, opinionChanged: nil ) - #expect(state.opinionChangeLabel == "처음 생각을 유지했어요") + #expect(state.opinionChangeLabel == "생각이 동일해요") } @Test @@ -850,9 +1254,443 @@ struct ClassBattleFlowTests { await store.finish() } + @Test + func 의견_더보기_메뉴는_Android와_같이_작성자와_상태로_정한다() { + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: false)) == .report) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: true)) == .editAndDelete) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: true, status: "REJECTED")) == .editOnly) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: true, status: "pending")) == .hidden) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: nil)) == .hidden) + #expect(!ClassPerspectiveMenuKind.hidden.hasMenu) + #expect(ClassPerspectiveMenuKind(myStatus: nil) == .editAndDelete) + } + + @Test + func 내_의견_수정은_입력창에_내용을_채우고_보내면_수정_후_다시_불러온다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + #expect(!state.shouldShowComposer) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.perspectiveUseCase = MockPerspectiveRepository() + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [perspectiveItem(id: 5, isMine: true)], nextCursor: nil, hasNext: false) + ) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveMoreTapped(5))) + #expect(store.state.menuTargetPerspectiveID == 5) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.menuTargetPerspectiveID == nil) + #expect(store.state.editingPerspectiveID == 5) + #expect(store.state.draft == "의견 5") + #expect(store.state.shouldShowComposer) + #expect(store.state.canSubmit) + + await store.send(.view(.draftChanged("고친 의견"))) + await store.send(.view(.submitTapped)) + #expect(store.state.mutationRequestID != nil) + #expect(store.state.submitRequestID == nil) + await store.receive(\.inner) // updatePerspectiveResponse + + #expect(store.state.editingPerspectiveID == nil) + #expect(store.state.draft.isEmpty) + #expect(store.state.mutationRequestID == nil) + #expect(store.state.perspectiveRequestID != nil || store.state.perspectives.map(\.id) == [5]) + } + + @Test + func 의견_수정을_취소하면_수정_상태와_입력이_비워진다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.view(.perspectiveEditTapped(5))) + await store.send(.view(.cancelEditTapped)) + + #expect(store.state.editingPerspectiveID == nil) + #expect(store.state.draft.isEmpty) + #expect(!store.state.shouldShowComposer) + } + + @Test + func 내_의견을_삭제하면_목록에서_빼고_다시_쓸_수_있다() async { + var state = completedState(perspectives: [ + perspectiveItem(id: 5, isMine: true), + perspectiveItem(id: 9, isMine: false), + ]) + state.hasPublishedPerspective = true + state.hasSubmittedInCurrentSession = true + state.myPerspective = perspectiveItem(id: 5, isMine: true) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.perspectiveUseCase = MockPerspectiveRepository() + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: ClassBattleFlowSpy()) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveDeleteTapped(9))) + #expect(store.state.customAlert == nil) + await store.send(.view(.perspectiveDeleteTapped(5))) + #expect(store.state.customAlert == .deletePerspective()) + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + await store.receive(\.inner) // deletePerspectiveResponse + + #expect(store.state.perspectives.map(\.id) == [9]) + #expect(store.state.myPerspective == nil) + #expect(store.state.hasPublishedPerspective == false) + #expect(!store.state.hasSubmittedInCurrentSession) + #expect(store.state.shouldShowComposer) + } + + @Test + func 남의_의견_신고는_확인_후_결과를_토스트로_알린다() async { + let state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.perspectiveUseCase = MockPerspectiveRepository() + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveReportTapped(9))) + #expect(store.state.customAlert == .report()) + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + await store.receive(\.inner) // reportPerspectiveResponse + #expect(store.state.toastMessage == "신고가 정상 접수되었습니다.") + await store.send(.view(.toastShown)) + #expect(store.state.toastMessage == nil) + + await store.send(.view(.perspectiveReportTapped(9))) + await store.send(.scope(.customAlert(.presented(.cancelTapped)))) + #expect(store.state.reportTargetPerspectiveID == nil) + #expect(store.state.mutationRequestID == nil) + + var duplicate = state + duplicate.mutationRequestID = requestID + let duplicateStore = TestStore(initialState: duplicate) { ClassBattleFlowFeature() } + duplicateStore.exhaustivity = .off + await duplicateStore.send(.inner(.reportPerspectiveResponse(requestID, .failure(.alreadyReported)))) + #expect(duplicateStore.state.toastMessage == "이미 신고한 사용자입니다.") + + var failed = state + failed.mutationRequestID = requestID + let failedStore = TestStore(initialState: failed) { ClassBattleFlowFeature() } + failedStore.exhaustivity = .off + await failedStore.send(.inner(.reportPerspectiveResponse(requestID, .failure(.unknown("x"))))) + #expect(failedStore.state.toastMessage == "신고하지 못했어요") + } + + @Test + func 검수_중인_내_의견은_목록_위_상태_카드로_보이고_메뉴가_없다() async { + var state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + state.myPerspective = perspectiveItem(id: 5, isMine: true, status: "PENDING") + #expect(state.hiddenMyPerspectiveRow?.perspective.id == 5) + #expect(state.hiddenMyPerspectiveRow?.statusBadgeTitle == "검수중") + #expect(state.hiddenMyPerspectiveRow?.menuKind == .hidden) + + state.myPerspective = perspectiveItem(id: 5, isMine: nil, status: "REJECTED") + #expect(state.hiddenMyPerspectiveRow?.statusBadgeTitle == "거절됨") + #expect(state.hiddenMyPerspectiveRow?.menuKind == .editOnly) + + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + state.myPerspective = perspectiveItem(id: 5, isMine: true, status: "PENDING") + let pendingStore = TestStore(initialState: state) { ClassBattleFlowFeature() } + pendingStore.exhaustivity = .off + await pendingStore.send(.view(.perspectiveMoreTapped(5))) + #expect(pendingStore.state.menuTargetPerspectiveID == nil) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.editingPerspectiveID == 5) + } + + @Test + func 상세_이동으로_화면이_가려져도_진행_중인_신고는_취소되지_않는다() async { + let gate = ReportGate() + let state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.perspectiveUseCase = SuspendedReportPerspective(gate: gate) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveReportTapped(9))) + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + await gate.waitForStart() + await store.send(.view(.onDisappear)) + #expect(store.state.mutationRequestID != nil) + await gate.release() + await store.receive(\.inner) // reportPerspectiveResponse + #expect(store.state.mutationRequestID == nil) + #expect(store.state.toastMessage == "신고가 정상 접수되었습니다.") + } + + @Test + func 의견_변경_요청이_진행_중이면_다른_메뉴_동작을_막는다() async { + var state = completedState(perspectives: [ + perspectiveItem(id: 5, isMine: true), + perspectiveItem(id: 9, isMine: false), + ]) + state.mutationRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.view(.perspectiveMoreTapped(9))) + #expect(store.state.menuTargetPerspectiveID == nil) + await store.send(.view(.perspectiveReportTapped(9))) + await store.send(.view(.perspectiveDeleteTapped(5))) + #expect(store.state.customAlert == nil) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.editingPerspectiveID == nil) + #expect(store.state.mutationRequestID == requestID) + } + + @Test + func 수정_중_같은_의견의_수정을_다시_눌러도_고치던_내용을_유지한다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.view(.perspectiveEditTapped(5))) + await store.send(.view(.draftChanged("고치는 중"))) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.draft == "고치는 중") + } + + @Test + func 의견을_제출한_뒤_완료_단계로_오면_내_의견을_다시_받아_검수_중_카드를_보여_준다() async throws { + var state = battleState(room: .mocks[0]) + state.loadRequestID = requestID + state.hasSubmittedInCurrentSession = true + let pending = perspectiveItem(id: 5, isMine: true, status: "PENDING") + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [pending] + ) + } + store.exhaustivity = .off + + await store.send(.inner(.progressResponse( + requestID, + .success(.init(session: session(step: .completed, commentRequired: false), voteHistory: nil)) + ))) + #expect(store.state.hasPublishedPerspective == true) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([pending]))) + + #expect(store.state.myPerspective?.id == 5) + #expect(store.state.hiddenMyPerspectiveRow?.statusBadgeTitle == "검수중") + #expect(store.state.hasPublishedPerspective == true) + } + + @Test + func 의견_필수_참여_완료면_내_의견이_비어도_입력창을_다시_열지_않는다() async throws { + var state = battleState(room: .mocks[0]) + state.loadRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [] + ) + } + store.exhaustivity = .off + + await store.send(.inner(.progressResponse( + requestID, + .success(.init( + session: session(step: .completed, commentRequired: true, participationCompleted: true), + voteHistory: nil + )) + ))) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([]))) + + #expect(store.state.hasPublishedPerspective == true) + #expect(!store.state.shouldShowComposer) + } + + @Test + func 판정이_끝난_뒤_내_의견_조회가_실패하면_목록_오류_문구를_덮지_않는다() async { + var state = completedState(perspectives: []) + state.hasPublishedPerspective = true + state.perspectiveErrorMessage = "목록 오류" + state.myPerspectiveRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.inner(.myPerspectiveResponse(requestID, .failure(.invalidResponse)))) + + #expect(store.state.perspectiveErrorMessage == "목록 오류") + #expect(store.state.hasPublishedPerspective == true) + } + + @Test + func 판정_전에_내_의견_조회가_실패하면_재시도_문구를_보여_준다() async { + var state = completedState(perspectives: []) + state.hasPublishedPerspective = nil + state.myPerspectiveRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.inner(.myPerspectiveResponse(requestID, .failure(.invalidResponse)))) + + #expect(store.state.perspectiveErrorMessage == ClassBattleError.invalidResponse.message) + #expect(store.state.hasPublishedPerspective == nil) + } + + @Test + func 내_의견_상세에서_돌아와_내_의견이_비면_입력창을_다시_연다() async throws { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [] + ) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveTapped(5))) + await store.send(.view(.onAppear)) + #expect(store.state.hasPublishedPerspective == nil) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([]))) + + #expect(store.state.hasPublishedPerspective == false) + #expect(store.state.shouldShowComposer) + } + + @Test + func 남의_의견_상세에서_돌아오면_내_의견이_비어도_참여_완료를_유지한다() async throws { + var state = completedState(perspectives: [ + perspectiveItem(id: 5, isMine: true), + perspectiveItem(id: 9, isMine: false), + ]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [] + ) + } + store.exhaustivity = .off + + // 내 의견을 누른 뒤 복귀 처리 없이 남의 의견을 누르면 앞의 표시가 남지 않아야 한다. + await store.send(.view(.perspectiveTapped(5))) + await store.send(.view(.perspectiveTapped(9))) + await store.send(.view(.onAppear)) + #expect(store.state.hasPublishedPerspective == true) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([]))) + + #expect(store.state.hasPublishedPerspective == true) + #expect(!store.state.shouldShowComposer) + } + + @Test + func 변경_요청_중에_알림을_확인하면_요청을_보내지_않고_대상을_비운다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + state.mutationRequestID = requestID + state.deleteTargetPerspectiveID = 5 + state.customAlert = .deletePerspective() + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + + #expect(store.state.customAlert == nil) + #expect(store.state.deleteTargetPerspectiveID == nil) + #expect(store.state.mutationRequestID == requestID) + #expect(store.state.perspectives.map(\.id) == [5]) + } + + @Test + func 목록과_내_의견이_모두_실패한_뒤_다시_시도하면_목록도_다시_불러온다() async { + var state = completedState(perspectives: []) + state.hasPublishedPerspective = nil + state.perspectiveErrorMessage = "목록 오류" + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: ClassBattleFlowSpy()) + } + store.exhaustivity = .off + + await store.send(.view(.retryMyPerspectiveTapped)) + + #expect(store.state.perspectiveRequestID != nil) + #expect(store.state.isLoadingPerspectives) + #expect(store.state.myPerspectiveRequestID != nil) + #expect(store.state.perspectiveErrorMessage == nil) + } + + @Test + func 목록이_있으면_다시_시도해도_내_의견만_다시_불러오고_목록은_유지한다() async { + var state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + state.hasPublishedPerspective = nil + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: ClassBattleFlowSpy()) + } + store.exhaustivity = .off + + await store.send(.view(.retryMyPerspectiveTapped)) + + #expect(store.state.perspectiveRequestID == nil) + #expect(store.state.perspectives.map(\.id) == [9]) + #expect(store.state.myPerspectiveRequestID != nil) + } + + private func completedState(perspectives: [ClassPerspectiveItem]) -> ClassBattleFlowFeature.State { + var state = battleState(room: .mocks[0]) + state.session = session(step: .completed, commentRequired: false) + state.viewState = .loaded + state.voteStats = .init(options: [], totalCount: 0, updatedAt: .now) + state.hasPublishedPerspective = false + state.perspectives = perspectives + return state + } + + private func perspectiveItem( + id: Int, + isMine: Bool?, + status: String? = nil + ) -> ClassPerspectiveItem { + ClassPerspectiveItem( + id: id, + author: .init(userTag: "u\(id)", nickname: "작성자 \(id)", characterType: nil, characterImageURL: nil), + option: .init(id: 1, label: "A", title: "찬성", stance: "찬성"), + content: "의견 \(id)", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: isMine, + status: status, + createdAt: .now + ) + } + private func session( step: ClassBattleStep, - commentRequired: Bool + commentRequired: Bool, + midVoteEnabled: Bool = false, + participationCompleted: Bool = false ) -> ClassBattleSession { ClassBattleSession( classID: 1, @@ -861,7 +1699,7 @@ struct ClassBattleFlowTests { closed: false, commentRequired: commentRequired, canParticipate: true, - participationCompleted: false, + participationCompleted: participationCompleted, battle: ClassBattleDetail( id: 10, title: "토론", @@ -874,7 +1712,8 @@ struct ClassBattleFlowTests { participantsCount: 1, audioDuration: 30, options: [] - ) + ), + midVoteEnabled: midVoteEnabled ) } @@ -892,12 +1731,18 @@ private actor ClassBattleFlowSpy { private(set) var scenarioBattleIDs: [Int] = [] private(set) var loadedURLs: [URL] = [] private(set) var timeSubscriptions = 0 + private(set) var midVoteArguments: [(Int, Int, Int)] = [] + private(set) var postVoteOptionIDs: [Int] = [] func recordClass(_ id: Int) { classIDs.append(id) } func recordVoteHistory(_ id: Int) { voteHistoryClassIDs.append(id) } func recordScenario(_ id: Int) { scenarioBattleIDs.append(id) } func recordAudio(_ url: URL) { loadedURLs.append(url) } func recordTimeSubscription() { timeSubscriptions += 1 } + func recordMidVote(classID: Int, checkpointID: Int, optionID: Int) { + midVoteArguments.append((classID, checkpointID, optionID)) + } + func recordPostVote(optionID: Int) { postVoteOptionIDs.append(optionID) } } private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { @@ -906,6 +1751,7 @@ private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { var history: ClassVoteHistory? var perspectivesPage: ClassPerspectivePage? var failsLoad = false + var myPerspectives: [ClassPerspectiveItem]? func fetchBattle(classID: Int) async throws -> ClassBattleSession { await spy.recordClass(classID) @@ -921,8 +1767,19 @@ private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { } func submitPreVote(classID _: Int, optionID _: Int) async throws -> ClassVoteReceipt { throw ClassBattleError.invalidResponse } + func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + await spy.recordMidVote(classID: classID, checkpointID: checkpointID, optionID: optionID) + return ClassVoteReceipt(voteID: 1, status: .ttsListening) + } func completeTTS(classID _: Int) async throws { throw ClassBattleError.invalidResponse } - func submitPostVote(classID _: Int, optionID _: Int) async throws -> ClassVoteReceipt { throw ClassBattleError.invalidResponse } + func submitPostVote(classID _: Int, optionID: Int) async throws -> ClassVoteReceipt { + await spy.recordPostVote(optionID: optionID) + throw ClassBattleError.invalidResponse + } func fetchVoteStats(classID _: Int) async throws -> ClassVoteStats { throw ClassBattleError.invalidResponse } func publishPerspective(classID _: Int, content _: String) async throws -> ClassPerspectiveReceipt { throw ClassBattleError.invalidResponse } func fetchPerspectives( @@ -935,7 +1792,112 @@ private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { guard let perspectivesPage else { throw ClassBattleError.invalidResponse } return perspectivesPage } - func fetchMyPerspectives(classID _: Int) async throws -> [ClassPerspectiveItem] { throw ClassBattleError.invalidResponse } + func fetchMyPerspectives(classID _: Int) async throws -> [ClassPerspectiveItem] { + guard let myPerspectives else { throw ClassBattleError.invalidResponse } + return myPerspectives + } +} + +/// 신고 요청을 멈춰 두었다가 풀어 주는 관문. +private actor ReportGate { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseContinuation: CheckedContinuation? + + func wait() async { + started = true + startWaiters.forEach { $0.resume() } + startWaiters = [] + await withCheckedContinuation { releaseContinuation = $0 } + } + + func waitForStart() async { + if started { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +/// 신고만 관문에서 멈추고 나머지는 Mock에 맡기는 의견 유스케이스. +private struct SuspendedReportPerspective: PerspectiveInterface { + let gate: ReportGate + private let base = MockPerspectiveRepository() + + init(gate: ReportGate) { + self.gate = gate + } + + func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + try await base.fetchPerspective(perspectiveId: perspectiveId) + } + + func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + try await base.fetchLabeledComments(perspectiveId: perspectiveId, cursor: cursor, size: size) + } + + func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + try await base.createComment(perspectiveId: perspectiveId, content: content) + } + + func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + try await base.updateComment(perspectiveId: perspectiveId, commentId: commentId, content: content) + } + + func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { + try await base.deleteComment(perspectiveId: perspectiveId, commentId: commentId) + } + + func updatePerspective( + perspectiveId: Int, + content: String + ) async throws { + try await base.updatePerspective(perspectiveId: perspectiveId, content: content) + } + + func deletePerspective(perspectiveId: Int) async throws { + try await base.deletePerspective(perspectiveId: perspectiveId) + } + + func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + try await base.likePerspective(perspectiveId: perspectiveId) + } + + func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + try await base.unlikePerspective(perspectiveId: perspectiveId) + } + + func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + try await base.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + + func reportPerspective(perspectiveId _: Int) async throws { + await gate.wait() + } + + func reportComment( + perspectiveId: Int, + commentId: Int + ) async throws { + try await base.reportComment(perspectiveId: perspectiveId, commentId: commentId) + } } private struct ClassBattleFlowScenarioStub: BattleInterface { diff --git a/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift b/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift index 2befa8cf..48066429 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift @@ -83,9 +83,9 @@ struct ClassMyOpinionsTests { let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } await store.send(.inner(.perspectivesResponse(.success([item])))) { $0.isLoading = false - $0.opinions = [ClassReportOpinion( + $0.perspectiveOpinions = [ClassReportOpinion( id: "77", - kind: .unspecified, + kind: .comment, body: "내 생각", stance: "찬성", likeCount: 4, @@ -93,6 +93,210 @@ struct ClassMyOpinionsTests { authorName: "나" )] } - #expect(store.state.unspecified.count == 1) + #expect(store.state.comments.count == 1) + } + + @Test + func 서버_내_의견은_검수_중과_거절만_빼고_알_수_없는_상태는_공개로_본다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.isLoading = true + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.perspectivesResponse(.success([ + perspective(id: 1, status: "PUBLISHED"), + perspective(id: 2, status: "PENDING"), + perspective(id: 3, status: "rejected"), + perspective(id: 4, status: nil), + perspective(id: 5, status: "HIDDEN_BY_ADMIN"), + ])))) + + #expect(store.state.perspectiveOpinions.map(\.id) == ["1", "4", "5"]) + } + + @Test + func 내_활동_댓글은_원_의견_제목과_내_이름을_가진_대댓글로_옮긴다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.myDisplayNameResponse(.success("민지")))) + await store.receive(\.async.fetchReplyPage) + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: "c-2"))))) + + #expect(store.state.replyOpinions == [ClassReportOpinion( + id: "reply-71", + kind: .reply, + body: "댓글 71", + likeCount: 2, + authorName: "민지", + parent: ClassReportReplyContext(body: "원 의견 71") + )]) + #expect(store.state.replies.first?.replyAuthorLabel == "민지의 답글") + #expect(store.state.replies.first?.parentStanceLabel == nil) + #expect(store.state.nextCursor == "c-2") + #expect(store.state.canLoadMoreReplies) + } + + @Test + func 표시_이름이_비면_나로_표시한다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.myDisplayName = "" + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: nil))))) + + #expect(store.state.replies.first?.replyAuthorLabel == "나의 답글") + } + + @Test + func 의견과_대댓글_응답은_도착_순서와_무관하게_서로를_덮어쓰지_않는다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.isLoading = true + state.myDisplayName = "민지" + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: nil))))) + await store.send(.inner(.perspectivesResponse(.success([perspective(id: 1, status: nil)])))) + + #expect(store.state.comments.map(\.opinion.id) == ["1"]) + #expect(store.state.replies.map(\.opinion.id) == ["reply-71"]) + #expect(!store.state.isEmpty) + } + + @Test + func 목록_끝에서_커서로_다음_대댓글을_이어_붙이고_첫_페이지는_교체한다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.myDisplayName = "민지" + let store = TestStore(initialState: state) { + ClassMyOpinionsFeature() + } withDependencies: { + $0.classResultUseCase = ActivityStub(pages: ["c-2": activity(ids: [72], next: nil)]) + } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: "c-2"))))) + await store.send(.view(.repliesEndReached)) + await store.receive(\.async.fetchReplyPage) + #expect(store.state.isLoadingMore) + await store.receive(\.inner.replyPageResponse) + + #expect(store.state.replyOpinions.map(\.id) == ["reply-71", "reply-72"]) + #expect(!store.state.canLoadMoreReplies) + #expect(!store.state.isLoadingMore) + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: nil))))) + #expect(store.state.replyOpinions.map(\.id) == ["reply-71"]) + } + + @Test + func 내_활동_조회에_실패하면_의견은_유지하고_토스트를_띄운다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.perspectiveOpinions = [ClassReportOpinion(id: "1", kind: .comment, body: "내 의견")] + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .failure(.network("실패"))))) + #expect(store.state.toastMessage == "정보를 불러오지 못했어요") + #expect(store.state.perspectiveOpinions.count == 1) + + await store.send(.view(.toastShown)) + #expect(store.state.toastMessage == nil) + + await store.send(.inner(.myDisplayNameResponse(.failure(.network("실패"))))) + #expect(store.state.toastMessage == "정보를 불러오지 못했어요") + } + + @Test + func 다음_대댓글_페이지_조회에_실패하면_더_불러오기를_멈춘다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.nextCursor = "c-2" + state.hasNext = true + state.isLoadingMore = true + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: "c-2", .failure(.network("실패"))))) + + #expect(!store.state.hasNext) + #expect(!store.state.canLoadMoreReplies) + #expect(store.state.toastMessage == "정보를 불러오지 못했어요") + } + + private func perspective( + id: Int, + status: String? + ) -> ClassPerspectiveItem { + ClassPerspectiveItem( + id: id, + author: .init(userTag: "member", nickname: "나", characterType: nil, characterImageURL: nil), + option: .init(id: 1, label: "A", title: "찬성", stance: "찬성"), + content: "의견 \(id)", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: true, + status: status, + createdAt: .now + ) + } + + private func activity( + ids: [Int], + next: String? + ) -> ClassMyActivity { + ClassMyActivity( + classID: 31, + receivedLikeCount: 0, + commentCount: ids.count, + comments: ids.map { + ClassMyActivityComment( + id: $0, + perspectiveID: $0 + 100, + perspectiveTitle: "원 의견 \($0)", + stance: "", + content: "댓글 \($0)", + likeCount: 2, + createdAt: nil + ) + }, + nextCursor: next, + hasNext: next != nil + ) + } + + private struct ActivityStub: ClassResultInterface { + let pages: [String: ClassMyActivity] + + func fetchResult(roomID _: Int) async throws -> ClassResult { + throw ClassError.network("사용하지 않음") + } + + func fetchPersonalResult(roomID _: Int) async throws -> ClassAIPersonalFeedback { + throw ClassAIFeedbackError.temporarilyUnavailable + } + + func fetchInsight(roomID _: Int) async throws -> ClassAIInsight { + throw ClassAIFeedbackError.temporarilyUnavailable + } + + func retryInsight(roomID _: Int) async throws {} + + func fetchMyActivity( + roomID _: Int, + cursor: String? + ) async throws -> ClassMyActivity { + guard let cursor, let page = pages[cursor] else { throw ClassError.network("페이지 없음") } + return page + } } } diff --git a/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift b/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift index d66a31c2..6850a659 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift @@ -258,6 +258,8 @@ struct ClassOwnerDashboardTests { let result: Result var feedback: ClassAIPersonalFeedback? = nil var insight: ClassAIInsight? = nil + var myActivity: ClassMyActivity? = nil + var suspendsMyActivity = false func fetchResult(roomID _: Int) async throws -> ClassResult { try result.get() @@ -276,6 +278,15 @@ struct ClassOwnerDashboardTests { func retryInsight(roomID _: Int) async throws { throw ClassAIFeedbackError.temporarilyUnavailable } + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + if suspendsMyActivity { try await Task.never() } + guard let myActivity else { throw ClassAIFeedbackError.temporarilyUnavailable } + return myActivity + } } private func serverResult( @@ -376,6 +387,163 @@ struct ClassOwnerDashboardTests { await store.receive(\.delegate.openMyOpinions) } + @Test + func 참여_운영자의_받은_추천은_내_활동_응답으로_표시한다() async { + let room = ClassRoom.mocks[0] + let requestID = UUID() + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + state.requestID = requestID + let result = ClassResult( + classID: room.id, + closed: true, + closeAt: room.deadline, + stats: serverResult(room).stats, + myReportState: "SUCCEEDED", + insightState: nil + ) + let activity = ClassMyActivity( + classID: room.id, + receivedLikeCount: 12, + commentCount: 3, + comments: [], + nextCursor: nil, + hasNext: false + ) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(result), + myActivity: activity + ) + } + store.exhaustivity = .off + #expect(store.state.personalReceivedLikeCountText == "—") + + await store.send(.inner(.resultResponse( + instanceID: state.instanceID, + requestID: requestID, + roomID: room.id, + .success(result) + ))) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == 12) + #expect(store.state.personalReceivedLikeCountText == "12") + #expect(store.state.myActivityRequestID == nil) + } + + @Test + func 내_활동_조회가_실패하면_받은_추천을_대시로_표시한다() async { + let room = ClassRoom.mocks[0] + let requestID = UUID() + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + state.requestID = requestID + let result = ClassResult( + classID: room.id, + closed: true, + closeAt: room.deadline, + stats: serverResult(room).stats, + myReportState: "SUCCEEDED", + insightState: nil + ) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub(result: .success(result)) + } + store.exhaustivity = .off + #expect(store.state.personalReceivedLikeCountText == "—") + + await store.send(.inner(.resultResponse( + instanceID: state.instanceID, + requestID: requestID, + roomID: room.id, + .success(result) + ))) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == nil) + #expect(store.state.personalReceivedLikeCountText == "—") + #expect(store.state.myActivityRequestID == nil) + } + + @Test + func 뒤로_가면_진행중인_내_활동_조회를_취소한다() async { + let room = ClassRoom.mocks[0] + let state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(serverResult(room)), + suspendsMyActivity: true + ) + } + store.exhaustivity = .off + + await store.send(.async(.fetchMyActivity)) + #expect(store.state.myActivityRequestID != nil) + await store.send(.view(.backTapped)) + await store.finish(timeout: .seconds(1)) + + #expect(store.state.myActivityRequestID == nil) + } + + @Test + func 다시_불러오면_받은_추천을_비우고_이전_내_활동_응답을_무시한다() async throws { + let room = ClassRoom.mocks[0] + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + state.receivedLikeCount = 12 + let activity = ClassMyActivity( + classID: room.id, + receivedLikeCount: 30, + commentCount: 0, + comments: [], + nextCursor: nil, + hasNext: false + ) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(serverResult(room)), + suspendsMyActivity: true + ) + } + store.exhaustivity = .off + + await store.send(.async(.fetchMyActivity)) + let staleRequestID = try #require(store.state.myActivityRequestID) + await store.send(.view(.retryTapped)) + await store.receive(\.async.fetchDashboard) + #expect(store.state.receivedLikeCount == nil) + #expect(store.state.myActivityRequestID == nil) + await store.receive(\.inner.resultResponse) + await store.send(.inner(.myActivityResponse( + instanceID: state.instanceID, + requestID: staleRequestID, + roomID: room.id, + .success(activity) + ))) + #expect(store.state.receivedLikeCount == nil) + await store.finish(timeout: .seconds(1)) + } + + @Test + func 개인_피드백이_없으면_내가_남긴_의견_버튼에_개수를_표시하지_않는다() { + let room = ClassRoom.mocks[0] + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + #expect(state.myOpinionsTitle == "내가 남긴 의견 0개 보기") + state.classResult = serverResult(room) + #expect(state.myOpinionsTitle == "내가 남긴 의견 보기") + state.generatedFeedback = ClassAIPersonalFeedback( + summaryTitle: "개인 결과", + summaryDetail: "참여 분석", + points: [], + nextQuestion: "다음 질문", + nextHint: "", + perspectiveCount: 2 + ) + #expect(state.myOpinionsTitle == "내가 남긴 의견 2개 보기") + } + @Test func 서버_현황_표시값은_State에서_계산한다() { let room = ClassRoom.mocks[0] diff --git a/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift b/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift index 9fe667c8..e060ead8 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift @@ -28,16 +28,26 @@ struct ClassReportTests { ]) } + @Test + func 주요_의견_본문은_따옴표로_감싸_표시한다() { + let row = ClassReportHighlightRow(id: 0, title: "주요 의견 1", text: "첫 인용", isLast: true) + + #expect(row.quotedText == "“첫 인용”") + } + private struct ResultStub: ClassResultInterface { let result: Result let feedback: ClassAIPersonalFeedback? + let myActivity: ClassMyActivity? init( result: Result, - feedback: ClassAIPersonalFeedback? = nil + feedback: ClassAIPersonalFeedback? = nil, + myActivity: ClassMyActivity? = nil ) { self.result = result self.feedback = feedback + self.myActivity = myActivity } func fetchResult(roomID _: Int) async throws -> ClassResult { @@ -56,25 +66,39 @@ struct ClassReportTests { func retryInsight(roomID _: Int) async throws { throw ClassAIFeedbackError.temporarilyUnavailable } + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + guard let myActivity else { throw ClassError.network("내 활동을 불러오지 못했습니다.") } + return myActivity + } } private func result( room: ClassRoom, reportState: String?, - postVoteRatio: Double = 54.5 + postVoteRatio: Double = 54.5, + activeMemberCount: Int = 12, + firstCount: Int = 6, + secondCount: Int = 5 ) -> ClassResult { ClassResult( classID: room.id, closed: true, closeAt: room.deadline, stats: .init( - activeMemberCount: 12, - completedCount: 10, + activeMemberCount: activeMemberCount, + completedCount: min(10, activeMemberCount), participationRate: 83.3, - commentWriterCount: 9, + commentWriterCount: min(9, activeMemberCount), commentRate: 75, - postVoteCount: 11, - options: [.init(id: 31, title: "창작이다", preVoteCount: 8, postVoteCount: 6, postVoteRatio: postVoteRatio)] + postVoteCount: firstCount + secondCount, + options: [ + .init(id: 31, title: "창작이다", preVoteCount: 8, postVoteCount: firstCount, postVoteRatio: postVoteRatio), + .init(id: 32, title: "아니다", preVoteCount: 4, postVoteCount: secondCount, postVoteRatio: 100 - postVoteRatio) + ] ), myReportState: reportState, insightState: nil @@ -93,27 +117,49 @@ struct ClassReportTests { #expect(state.serverCommentRate == "75%") #expect(state.serverParticipationCount == "10 / 12명") #expect(state.serverCommentCount == "9 / 12명") - #expect(abs(state.serverParticipationProgress - 0.833) < 0.0001) + #expect(abs(state.serverParticipationProgress - 0.833) < 0.001) #expect(abs(state.serverCommentProgress - 0.75) < 0.0001) let option = try #require(state.classResult?.stats.options.first) #expect(state.serverOptionCounts[option.id] == "사전 8 · 사후 6") #expect(state.serverDistributionTotalText == "11명") - #expect(abs(state.serverDistributionProgress - 0.545) < 0.0001) + #expect(abs(state.serverDistributionProgress - (6.0 / 11.0)) < 0.0001) #expect(state.serverDistributionOptionCountTexts[option.id] == "6명") #expect(state.serverDistributionOptionRatioTexts[option.id] == "54.5%") + #expect(state.serverDistributionOptionRatioTexts[32] == "45.5%") state.classResult = result( room: .mockClosedSociety, reportState: "SUCCEEDED", postVoteRatio: -10 ) - #expect(state.serverDistributionProgress == 0) + #expect(abs(state.serverDistributionProgress - (6.0 / 11.0)) < 0.0001) state.classResult = result( room: .mockClosedSociety, reportState: "SUCCEEDED", postVoteRatio: 110 ) - #expect(state.serverDistributionProgress == 1) + #expect(abs(state.serverDistributionProgress - 0.545) < 0.001) + } + + @Test + func 참여_대상과_사후_투표가_없으면_미참여_상태를_표시한다() { + var state = ClassReportFeature.State(room: .mockClosedSociety, instanceID: UUID()) + state.classResult = result( + room: .mockClosedSociety, + reportState: "SUCCEEDED", + activeMemberCount: 0, + firstCount: 0, + secondCount: 0 + ) + #expect(state.serverParticipationRate == "아직 참여 없음") + #expect(state.serverCommentRate == "아직 참여 없음") + #expect(state.serverParticipationProgress == 0) + #expect(state.serverCommentProgress == 0) + #expect(state.serverDistributionTotalCount == 0) + #expect(state.serverDistributionTotalText == "0명") + #expect(state.serverDistributionProgress == 0) + #expect(state.serverDistributionOptionRatioTexts[31] == "아직 참여 없음") + #expect(state.serverDistributionOptionRatioTexts[32] == "아직 참여 없음") } @Test @@ -358,4 +404,86 @@ struct ClassReportTests { await store.receive(\.inner.resultResponse) #expect(store.state.sourceError == .resultExpired) } + + @Test + func 참여자_리포트의_받은_추천은_내_활동_응답으로_표시한다() async { + let room = ClassRoom.mockClosedSociety + let activity = ClassMyActivity( + classID: room.id, + receivedLikeCount: 12, + commentCount: 3, + comments: [], + nextCursor: nil, + hasNext: false + ) + let store = TestStore(initialState: ClassReportFeature.State(room: room, instanceID: UUID())) { + ClassReportFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(result(room: room, reportState: "SUCCEEDED")), + myActivity: activity + ) + } + store.exhaustivity = .off + + #expect(store.state.personalReceivedLikeCountText == "—") + await store.send(.view(.onAppear)) + await store.receive(\.async.fetchMyActivity) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == 12) + #expect(store.state.personalReceivedLikeCountText == "12") + #expect(store.state.myActivityRequestID == nil) + + await store.send(.async(.fetch)) + #expect(store.state.receivedLikeCount == nil) + } + + @Test + func 내_활동_조회에_실패하면_받은_추천을_대시로_둔다() async { + let room = ClassRoom.mockClosedSociety + let store = TestStore(initialState: ClassReportFeature.State(room: room, instanceID: UUID())) { + ClassReportFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub(result: .success(result(room: room, reportState: "SUCCEEDED"))) + } + store.exhaustivity = .off + + await store.send(.view(.onAppear)) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == nil) + #expect(store.state.personalReceivedLikeCountText == "—") + } + + @Test + func 개인_리포트_권한이_없으면_내_활동을_조회하지_않는다() async { + let room = ClassRoom.mockClosedSociety + let store = TestStore(initialState: ClassReportFeature.State(room: room, instanceID: UUID())) { + ClassReportFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(result(room: room, reportState: nil)), + myActivity: ClassMyActivity( + classID: room.id, + receivedLikeCount: 12, + commentCount: 0, + comments: [], + nextCursor: nil, + hasNext: false + ) + ) + } + store.exhaustivity = .off + + await store.send(.view(.onAppear)) + await store.receive(\.inner.resultResponse) + await store.finish() + + // 조회했다면 대역이 돌려주는 12가 들어간다. + #expect(store.state.receivedLikeCount == nil) + } } diff --git a/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift b/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift index c8513f75..b6ca8b74 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift @@ -56,6 +56,61 @@ struct ClassSettingTests { #expect(ai.aiCreation?.ownerParticipates == true) } + @Test + func 생성_요청_키는_같은_내용의_재시도에_유지되고_내용을_바꾸면_바뀐다() throws { + let instanceID = try #require(UUID(uuidString: "3F2B8C1E-7D4A-4F6B-9A0E-2C5D8E1F4A7B")) + var state = ClassSettingFeature.State( + battle: battle, + deadline: deadline, + instanceID: instanceID + ) + state.name = "토론 클래스" + let firstKey = state.creation?.requestKey + state.name = "토론 클래스 " + + #expect(firstKey?.count == 64) + #expect(state.creation?.requestKey == firstKey) + + state.name = "다른 토론 클래스" + #expect(state.creation?.requestKey != firstKey) + state.name = "토론 클래스" + #expect(state.creation?.requestKey == firstKey) + + state.deadline = deadline.addingTimeInterval(60) + #expect(state.creation?.requestKey != firstKey) + state.deadline = deadline + state.isDeadlineEnabled = false + #expect(state.creation?.requestKey != firstKey) + state.isDeadlineEnabled = true + + state.requiresComment = true + #expect(state.creation?.requestKey != firstKey) + state.requiresComment = false + state.ownerParticipates = true + #expect(state.creation?.requestKey != firstKey) + state.ownerParticipates = false + #expect(state.creation?.requestKey == firstKey) + + var other = ClassSettingFeature.State(battle: battle, deadline: deadline) + other.name = state.name + #expect(other.creation?.requestKey != firstKey) + + state.aiQuestion = ClassAIQuestion(version: topicVersion) + state.topicVersionID = topicVersion.id + #expect(state.aiCreation?.requestKey != firstKey) + #expect(state.creation?.requestKey == firstKey) + + state.battle = nil + let aiKey = state.creation?.requestKey + #expect(aiKey == state.aiCreation?.requestKey) + #expect(aiKey != firstKey) + state.topicVersionID = topicVersion.id + 1 + #expect(state.creation?.requestKey != aiKey) + + state.battle = ClassBattleSummary.mocks[1] + #expect(state.creation?.requestKey != firstKey) + } + @Test func 클래스를_만들면_생성_델리게이트를_보낸다() async throws { var state = ClassSettingFeature.State(battle: battle, deadline: deadline) diff --git a/Projects/Feature/Class/Tests/Sources/ClassTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTests.swift index a5ffd5eb..da6bc93d 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassTests.swift @@ -8,6 +8,7 @@ @testable import Class import ClassDomainInterface import ComposableArchitecture +import Foundation import Testing @MainActor @@ -43,15 +44,113 @@ struct ClassTests { } @Test - func 새_클래스_만들기_탭하면_생성_델리게이트를_보낸다() async { + func 새_클래스_만들기는_사용_중인_이용권이면_생성_델리게이트를_보낸다() async { let store = TestStore(initialState: ClassIntroFeature.State()) { ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .success(introTicket(.active))) } - await store.send(.view(.createTapped)) + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { $0.isCheckingTicket = false } await store.receive(\.delegate.create) } + @Test + func 새_클래스_만들기는_이용권이_만료되면_등록_안내를_띄우고_등록하기로_이동한다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .success(introTicket(.expired))) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.ticketNotice = .expired + $0.customAlert = .classTicketExpired() + } + await store.send(.customAlert(.presented(.confirmTapped))) { + $0.ticketNotice = nil + $0.customAlert = nil + } + await store.receive(\.delegate.registerTicket) + } + + @Test + func 새_클래스_만들기는_만료_안내에서_뒤로가기를_누르면_머문다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .success(introTicket(.expired))) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.ticketNotice = .expired + $0.customAlert = .classTicketExpired() + } + await store.send(.customAlert(.presented(.cancelTapped))) { + $0.ticketNotice = nil + $0.customAlert = nil + } + } + + @Test + func 새_클래스_만들기는_이용권이_없으면_안내만_띄운다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .failure(.notFound)) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.ticketNotice = .required + $0.customAlert = .classTicketRequired() + } + await store.send(.customAlert(.presented(.confirmTapped))) { + $0.ticketNotice = nil + $0.customAlert = nil + } + } + + @Test + func 새_클래스_만들기에서_이용권_조회가_실패하면_토스트를_띄운다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .failure(.network("연결 실패"))) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.toastMessage = "정보를 불러오지 못했어요" + } + await store.send(.view(.toastShown)) { $0.toastMessage = nil } + } + + @Test + func 이용권_확인_중_다시_누르면_무시하고_뒤로_가면_확인을_취소한다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicketFetch: { + try await Task.sleep(for: .seconds(60)) + throw ClassTicketError.notFound + }) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.send(.view(.createTapped)) + await store.send(.view(.backTapped)) { $0.isCheckingTicket = false } + await store.receive(\.delegate.backToHome) + await store.finish(timeout: .seconds(1)) + } + @Test func 이용권_탭하면_등록_화면으로_이동한다() async { let store = TestStore(initialState: ClassIntroFeature.State()) { @@ -118,3 +217,15 @@ struct ClassTests { )) } } + +private func introTicket(_ status: ClassTicket.Status) -> ClassTicket { + ClassTicket( + id: 3, + capacity: 15, + status: status, + activatedAt: Date(timeIntervalSince1970: 1_780_000_000), + expiresAt: Date(timeIntervalSince1970: 1_795_000_000), + remainingDays: 0, + readGraceEndsAt: Date(timeIntervalSince1970: 1_797_592_000) + ) +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift index 77cbc8d1..98fa35ba 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift @@ -28,7 +28,7 @@ struct ClassTicketTests { $0.shouldCheckExistingTicket = false $0.isLoadingExistingTicket = true } - await store.receive(\.myTicketResponse) { + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false $0.ticket = ticket $0.registrationStartDate = start @@ -63,7 +63,7 @@ struct ClassTicketTests { } #expect(!store.state.canCheckCode) await gate.complete(ticket) - await store.receive(\.myTicketResponse) { + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false $0.ticket = ticket $0.registrationStartDate = ticket.activatedAt @@ -82,14 +82,14 @@ struct ClassTicketTests { $0.shouldCheckExistingTicket = false $0.isLoadingExistingTicket = true } - await store.receive(\.myTicketResponse) { $0.isLoadingExistingTicket = false } + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false } #expect(store.state.phase == .entry) - #expect(store.state.errorMessage == nil) + #expect(store.state.customAlert == nil) await store.send(.view(.onAppear)) } @Test - func 만료된_이용권은_사용_중으로_표시하지_않는다() async { + func 만료된_이용권은_개요_대신_코드_입력부터_시작한다() async { let ticket = ClassTicket( id: 3, capacity: 15, @@ -107,14 +107,31 @@ struct ClassTicketTests { $0.shouldCheckExistingTicket = false $0.isLoadingExistingTicket = true } - await store.receive(\.myTicketResponse) { + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false } + #expect(store.state.phase == .entry) + #expect(store.state.ticket == nil) + } + + @Test + func 진입_조회가_실패하면_토스트를_띄운_뒤_화면을_닫는다() async { + let store = TestStore(initialState: ClassTicketFeature.State()) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .failure(.network("연결 실패"))) + } + + await store.send(.view(.onAppear)) { + $0.shouldCheckExistingTicket = false + $0.isLoadingExistingTicket = true + } + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false - $0.ticket = ticket - $0.registrationStartDate = ticket.activatedAt - $0.expirationDate = ticket.expiresAt - $0.phase = .overview + $0.toastMessage = "정보를 불러오지 못했어요" + $0.closesAfterToast = true + } + await store.send(.view(.toastShown)) { + $0.toastMessage = nil + $0.closesAfterToast = false } - #expect(store.state.overviewTitle == "만료된 이용권") + await store.receive(\.delegate.dismiss) } @Test @@ -207,15 +224,77 @@ struct ClassTicketTests { $0.isLoadingExistingTicket = true $0.preview = preview } - await store.receive(\.myTicketResponse) { + await store.receive(\.registeredTicketResponse) { $0.isLoadingExistingTicket = false } + await store.receive(\.delegate, .existingTicketFound(ticket)) + } + + @Test + func 내가_등록한_만료_이용권_코드는_만료_안내를_띄운다() async { + let preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: true + ) + let ticket = ClassTicket( + id: 3, + capacity: 30, + status: .expired, + activatedAt: Date(timeIntervalSince1970: 1_780_000_000), + expiresAt: Date(timeIntervalSince1970: 1_795_000_000), + remainingDays: 0, + readGraceEndsAt: Date(timeIntervalSince1970: 1_797_592_000) + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(preview: .success(preview), myTicket: .success(ticket)) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.isLoadingExistingTicket = true + $0.preview = preview + } + await store.receive(\.registeredTicketResponse) { $0.isLoadingExistingTicket = false - $0.ticket = ticket - $0.phase = .overview - $0.registrationStartDate = start - $0.expirationDate = end + $0.registerNotice = .registrationExpired + $0.customAlert = .ticketRegistrationExpired() } } + @Test + func 내_코드_재확인_중_코드를_고치면_조회를_취소한다() async { + let preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: true + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(preview: .success(preview), myTicketFetch: { + try await Task.sleep(for: .seconds(60)) + throw ClassTicketError.notFound + }) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.isLoadingExistingTicket = true + $0.preview = preview + } + await store.send(.binding(.set(\.code, "PICKE-ZZ99"))) { + $0.code = "PICKE-ZZ99" + $0.preview = nil + $0.isLoadingExistingTicket = false + } + await store.finish(timeout: .seconds(1)) + } + @Test func 서버_거절_사유를_보여주고_등록을_막는다() async { var state = ClassTicketFeature.State() @@ -227,24 +306,206 @@ struct ClassTicketTests { await store.send(.view(.checkTapped)) { $0.isChecking = true } await store.receive(\.checkResponse) { $0.isChecking = false - $0.errorMessage = ClassTicketError.registrationExpired.message + $0.registerNotice = .registrationExpired + $0.customAlert = .ticketRegistrationExpired() } #expect(store.state.phase == .entry) } + + @Test + func 다른_계정에_등록된_코드_안내는_배경_탭이면_머물고_뒤로가기면_닫는다() async { + var state = ClassTicketFeature.State() + state.code = "CLAIMED" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(preview: .failure(.claimed)) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.registerNotice = .alreadyRegistered + $0.customAlert = .ticketAlreadyRegistered() + } + await store.send(.customAlert(.presented(.cancelTapped))) { + $0.registerNotice = nil + $0.customAlert = nil + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.registerNotice = .alreadyRegistered + $0.customAlert = .ticketAlreadyRegistered() + } + await store.send(.customAlert(.presented(.confirmTapped))) { + $0.registerNotice = nil + $0.customAlert = nil + } + await store.receive(\.delegate.dismiss) + } + + @Test + func 등록_오류는_Android와_같은_안내로_바꾸고_사용_중_이용권만_따로_안내한다() { + #expect(ClassTicketRegisterNotice(error: .claimed) == .alreadyRegistered) + #expect(ClassTicketRegisterNotice(error: .invalidCode) == .invalidCode) + #expect(ClassTicketRegisterNotice(error: .revoked) == .invalidCode) + #expect(ClassTicketRegisterNotice(error: .registrationExpired) == .registrationExpired) + #expect(ClassTicketRegisterNotice(error: .activeTicket) == .alreadyActive) + #expect(ClassTicketRegisterNotice(error: .notFound) == .registerFailed) + #expect(ClassTicketRegisterNotice(error: .invalidResponse) == .registerFailed) + #expect(ClassTicketRegisterNotice(error: .network("x")) == .registerFailed) + } + + @Test + func 확인_단계에서_뒤로가면_코드_입력으로_돌아가고_등록_중에는_무시한다() async { + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + state.phase = .preview + state.preview = ClassTicketPreview( + capacity: 15, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + state.isChecking = true + let store = TestStore(initialState: state) { ClassTicketFeature() } + + await store.send(.view(.backTapped)) + await store.send(.binding(.set(\.isChecking, false))) { $0.isChecking = false } + await store.send(.view(.backTapped)) { + $0.phase = .entry + $0.preview = nil + } + } + + @Test + func 코드는_영문_숫자_하이픈만_대문자로_남긴다() { + var state = ClassTicketFeature.State() + state.code = " picke-ab12 cd34!한" + #expect(state.normalizedCode == "PICKE-AB12CD34") + #expect(state.canCheckCode) + state.code = " !한 " + #expect(!state.canCheckCode) + #expect(state.ticketPeriodText == "") + state.code = "1\u{FE0F}\u{20E3}A\u{0332}" + #expect(state.normalizedCode == "1A") + } + + @Test + func 코드_확인과_등록은_정규화한_코드를_보낸다() async { + let codes = LockIsolated<[String]>([]) + let preview = ClassTicketPreview( + capacity: 15, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + let ticket = ClassTicket( + id: 3, + capacity: 15, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_780_000_000), + expiresAt: Date(timeIntervalSince1970: 1_795_000_000), + remainingDays: 162, + readGraceEndsAt: Date(timeIntervalSince1970: 1_797_592_000) + ) + var state = ClassTicketFeature.State() + state.code = " picke-ab12 cd34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub( + preview: .success(preview), + register: .success(ticket), + submittedCodes: codes + ) + } + store.exhaustivity = .off + + await store.send(.view(.checkTapped)) + await store.receive(\.checkResponse) + await store.send(.view(.confirmTapped)) + await store.receive(\.registrationResponse) + + #expect(codes.value == ["PICKE-AB12CD34", "PICKE-AB12CD34"]) + } + + @Test + func 내_코드_재확인_조회가_실패하면_등록_실패_안내를_띄운다() async { + let preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: true + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub( + preview: .success(preview), + myTicket: .failure(.network("연결 실패")) + ) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.isLoadingExistingTicket = true + $0.preview = preview + } + await store.receive(\.registeredTicketResponse) { + $0.isLoadingExistingTicket = false + $0.registerNotice = .registerFailed + $0.customAlert = .ticketRegisterFailed() + } + } + + @Test + func 등록이_사용_중_이용권으로_거절되면_사용_중_안내를_띄운다() async { + let preview = ClassTicketPreview( + capacity: 15, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub( + preview: .success(preview), + register: .failure(.activeTicket) + ) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.preview = preview + $0.phase = .preview + } + await store.send(.view(.confirmTapped)) { $0.isChecking = true } + await store.receive(\.registrationResponse) { + $0.isChecking = false + $0.registerNotice = .alreadyActive + $0.customAlert = .ticketAlreadyActive() + } + #expect(store.state.phase == .preview) + } } -private struct TicketStub: ClassTicketInterface { +struct TicketStub: ClassTicketInterface { var preview: Result = .failure(.invalidCode) var register: Result = .failure(.invalidCode) var myTicket: Result = .failure(.notFound) var myTicketFetch: (@Sendable () async throws -> ClassTicket)? + var submittedCodes: LockIsolated<[String]>? - func previewTicket(code _: String) async throws -> ClassTicketPreview { - try preview.get() + func previewTicket(code: String) async throws -> ClassTicketPreview { + submittedCodes?.withValue { $0.append(code) } + return try preview.get() } - func registerTicket(code _: String) async throws -> ClassTicket { - try register.get() + func registerTicket(code: String) async throws -> ClassTicket { + submittedCodes?.withValue { $0.append(code) } + return try register.get() } func fetchMyTicket() async throws -> ClassTicket { diff --git a/Projects/Feature/Class/Tests/Sources/MyClassTests.swift b/Projects/Feature/Class/Tests/Sources/MyClassTests.swift index 652e8d4c..167fcb53 100644 --- a/Projects/Feature/Class/Tests/Sources/MyClassTests.swift +++ b/Projects/Feature/Class/Tests/Sources/MyClassTests.swift @@ -13,6 +13,31 @@ import Testing @MainActor struct MyClassTests { + @Test + func 목록_500_실패는_빈_클래스로_표시하지_않고_재시도한다() async { + let store = TestStore(initialState: MyClassFeature.State()) { MyClassFeature() } withDependencies: { + $0.classRoomUseCase = MockClassRepository(rooms: [], availableRooms: []) + } + + await store.send(.inner(.roomsResponse(0, .failure(.network("HTTP 500"))))) { + $0.viewState = .error + $0.errorMessage = "HTTP 500" + } + #expect(store.state.rooms.isEmpty) + #expect(store.state.viewState == .error) + await store.send(.view(.retryTapped)) + await store.receive(\.async, .fetchRooms) { + $0.fetchGeneration = 1 + $0.isLoading = true + $0.errorMessage = nil + $0.viewState = .loading + } + await store.receive(\.inner, .roomsResponse(1, .success([]))) { + $0.isLoading = false + $0.viewState = .empty + } + } + @Test func 진행_상태로_필터링한다() async { var state = MyClassFeature.State() diff --git a/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift index 8cbed6c9..7edfb2d3 100644 --- a/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift @@ -34,6 +34,7 @@ public struct HomeFeature { public var heroIndex: Int = 0 public var hotBattles: [HotBattle] = [] public var bestBattles: [BestBattle] = [] + public var bestComments: [HomeBestComment] = [] public var quizzes: [QuizQuestion] = [] public var votes: [VoteQuestion] = [] public var newBattles: [NewBattle] = [] @@ -49,11 +50,17 @@ public struct HomeFeature { public var currentQuiz: QuizQuestion? { quizzes.first } public var currentVote: VoteQuestion? { votes.first } + public var hasVisibleHomeContent: Bool { + !heroes.isEmpty || !hotBattles.isEmpty || !bestBattles.isEmpty || + !bestComments.isEmpty || !newBattles.isEmpty + } + public var shouldShowSkeleton: Bool { (viewState == .loading || !hasLoadedHome) && heroes.isEmpty && hotBattles.isEmpty && bestBattles.isEmpty && + bestComments.isEmpty && quizzes.isEmpty && votes.isEmpty && newBattles.isEmpty @@ -80,6 +87,7 @@ public struct HomeFeature { case heroTapped(HeroBattle) case hotBattleTapped(HotBattle) case bestBattleTapped(BestBattle) + case bestCommentTapped(HomeBestComment) case newBattleTapped(NewBattle) case notificationTapped /// 홈 피드 네이티브 광고 클릭 @@ -205,6 +213,13 @@ extension HomeFeature { ))) return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + case let .bestCommentTapped(comment): + analyticsUseCase.track(.contentAction(ContentActionData( + action: .battleCardTap, + contentID: "\(comment.battleId)" + ))) + return .send(.delegate(.presentPreVote(battleId: comment.battleId))) + case let .newBattleTapped(battle): analyticsUseCase.track(.contentAction(ContentActionData( action: .newBattleTap, @@ -278,6 +293,7 @@ extension HomeFeature { state.heroIndex = 0 state.hotBattles = home.hotBattles state.bestBattles = home.bestBattles + state.bestComments = home.bestComments state.quizzes = home.quizzes state.votes = home.votes state.newBattles = home.newBattles diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeBestCommentRow.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeBestCommentRow.swift new file mode 100644 index 00000000..17370564 --- /dev/null +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeBestCommentRow.swift @@ -0,0 +1,46 @@ +// +// HomeBestCommentRow.swift +// Home +// + +import HomeDomainInterface +import PickeDesignKit +import SwiftUI + +struct HomeBestCommentRow: View { + let comment: HomeBestComment + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(comment.battleTitle) + .pretendardFont(.semiBold12) + .foregroundStyle(.neutral300) + .lineLimit(1) + + HStack(alignment: .top, spacing: 4) { + Image(systemName: "arrow.turn.down.right") + .foregroundStyle(.primary500) + .frame(width: 16, height: 16) + Text(attributedContent) + .pretendardFont(.bodySmall) + .foregroundStyle(.neutral500) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var attributedContent: AttributedString { + guard !comment.optionTitle.isEmpty else { + return AttributedString(comment.content) + } + var text = AttributedString(" \(comment.optionTitle) \(comment.content)") + if let range = text.range(of: comment.optionTitle) { + text[range].foregroundColor = .primary500 + text[range].backgroundColor = .beige600 + } + return text + } +} diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift index 620ba433..8ec60a4b 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift @@ -12,7 +12,7 @@ import PickeDesignKit /// "지금 뜨는 배틀 / 더 보기" 같은 섹션 헤더 공통 컴포넌트. struct HomeSectionHeader: View { let title: String - let onSeeMoreTapped: () -> Void + let onSeeMoreTapped: (() -> Void)? var body: some View { HStack(spacing: 12) { @@ -20,35 +20,23 @@ struct HomeSectionHeader: View { .pretendardFont(.bold18) .kerning(-0.45) Spacer(minLength: 0) - Button(action: onSeeMoreTapped) { - Text("더 보기") - .pretendardFont(.medium13) - .foregroundStyle(.neutral300) + if let onSeeMoreTapped { + Button(action: onSeeMoreTapped) { + Text("더 보기") + .pretendardFont(.medium13) + .foregroundStyle(.neutral300) + } } } .padding(.horizontal, 16) } - /// 강조 규칙: - /// - 라틴 단어 (Best · Pické 등) 가 있으면 그 단어만 primary500, 나머지 한글은 neutral900 - /// - 라틴 단어가 없으면 한글 `배틀` 만 primary500, 나머지 한글은 neutral900 + /// Figma 홈 헤더: 섹션 주제어(배틀/댓글)만 primary500으로 강조한다. private var attributedTitle: AttributedString { var attr = AttributedString(title) attr.foregroundColor = .neutral900 - - let latinPattern = /[A-Za-zÀ-ÿ]+/ - let latinMatches = Array(title.matches(of: latinPattern)) - - if latinMatches.isEmpty { - if let range = attr.range(of: "배틀") { - attr[range].foregroundColor = .primary500 - } - } else { - for match in latinMatches { - if let range = attr.range(of: String(match.0)) { - attr[range].foregroundColor = .primary500 - } - } + if let range = attr.range(of: "배틀") ?? attr.range(of: "댓글") { + attr[range].foregroundColor = .primary500 } return attr } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift index 9cfd5436..95595c1e 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift @@ -15,7 +15,7 @@ struct HomeSkeletonView: View { HomeHeroSkeletonView() HomeHotBattlesSkeletonView() HomeBestBattlesSkeletonView() - HomeTodayPickeSkeletonView() + HomeBestCommentsSkeletonView() HomeNewBattlesSkeletonView() } .padding(.bottom, 24) @@ -119,70 +119,28 @@ private struct HomeBestBattlesSkeletonView: View { } } -private struct HomeTodayPickeSkeletonView: View { +private struct HomeBestCommentsSkeletonView: View { var body: some View { - VStack(alignment: .leading, spacing: 16) { - SkeletonSectionHeader(width: 104) - VStack(spacing: 16) { - todayQuizCard() - todayVoteCard() - } - .padding(.horizontal, 16) - } - } - - @ViewBuilder - private func todayQuizCard() -> some View { - VStack(alignment: .leading, spacing: 20) { - HStack { - SkeletonBlock(width: 42, height: 20, color: .primary50) - Spacer() - SkeletonBlock(width: 76, height: 12) - } - VStack(spacing: 8) { - SkeletonBlock(width: 220, height: 16) - SkeletonBlock(width: 260, height: 12) - SkeletonBlock(width: 180, height: 12) - } - .frame(maxWidth: .infinity) - - HStack(spacing: 8) { - SkeletonBlock(height: 74, color: .beige50) - SkeletonBlock(height: 74, color: .beige50) - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .pickeCard(.beige400, border: .beige700) - } - - @ViewBuilder - private func todayVoteCard() -> some View { - VStack(alignment: .leading, spacing: 20) { - HStack { - SkeletonBlock(width: 42, height: 20, color: .primary50) - Spacer() - SkeletonBlock(width: 76, height: 12) - } - VStack(spacing: 8) { - HStack(spacing: 8) { - SkeletonBlock(width: 78, height: 16) - SkeletonBlock(width: 44, height: 24, color: .beige50) - SkeletonBlock(width: 24, height: 16) - } - SkeletonBlock(width: 230, height: 12) - } - .frame(maxWidth: .infinity) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), spacing: 8) { - ForEach(0 ..< 4, id: \.self) { _ in - SkeletonBlock(height: 44, color: .beige50) + VStack(alignment: .leading, spacing: 12) { + SkeletonSectionHeader(width: 100) + VStack(spacing: 0) { + ForEach(0 ..< 3, id: \.self) { index in + VStack(alignment: .leading, spacing: 8) { + SkeletonBlock(width: 220, height: 12) + HStack(spacing: 4) { + SkeletonBlock(width: 16, height: 16) + SkeletonBlock(width: 48, height: 16, color: .beige600) + SkeletonBlock(width: 210, height: 12) + } + } + .padding(16) + if index < 2 { + Divider().background(.beige600) + } } } + .padding(.horizontal, 16) } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .pickeCard(.beige50, border: .beige700) } } diff --git a/Projects/Feature/Home/Sources/Main/View/HomeView.swift b/Projects/Feature/Home/Sources/Main/View/HomeView.swift index 86ac10ef..8565a0ad 100644 --- a/Projects/Feature/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Feature/Home/Sources/Main/View/HomeView.swift @@ -32,6 +32,8 @@ public struct HomeView: View { ScrollView(showsIndicators: false) { if store.shouldShowSkeleton { HomeSkeletonView() + } else if !store.hasVisibleHomeContent { + emptyHomeSection() } else { VStack(spacing: 32) { if !store.heroes.isEmpty { @@ -42,18 +44,15 @@ public struct HomeView: View { ) } - if !store.hotBattles.isEmpty { - hotBattlesSection() + hotBattlesSection() + if !store.hotBattles.isEmpty || !store.bestBattles.isEmpty { adSection() } if !store.bestBattles.isEmpty { bestBattlesSection() - if store.hotBattles.isEmpty { - adSection() - } } - if !store.quizzes.isEmpty || !store.votes.isEmpty { - todayPickeSection() + if !store.bestComments.isEmpty { + bestCommentsSection() } if !store.newBattles.isEmpty { newBattlesSection() @@ -82,6 +81,21 @@ public struct HomeView: View { // MARK: - Sections extension HomeView { + @ViewBuilder + private func emptyHomeSection() -> some View { + VStack(spacing: 16) { + Image(asset: .noDataLogo) + .resizable() + .scaledToFit() + .frame(width: 160, height: 120) + Text("아직 준비된 배틀이 없어요!") + .pretendardFont(.bodyMedium) + .foregroundStyle(.beige800) + } + .frame(maxWidth: .infinity) + .frame(minHeight: 500) + } + /// 배틀 섹션 아래 네이티브 광고 — AdFit 은 한 화면에 같은 단위 1개만 허용해 호출부에서 하나만 그린다. @ViewBuilder private func adSection() -> some View { @@ -98,53 +112,61 @@ extension HomeView { HomeSectionHeader(title: "지금 뜨는 배틀") { send(.seeMoreTapped(.hotBattles)) } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(store.hotBattles) { battle in - HotBattleCardView(battle: battle) - .contentShape(Rectangle()) - .onTapGesture { send(.hotBattleTapped(battle)) } + if store.hotBattles.isEmpty { + Text("지금 뜨는 배틀이 아직 없어요") + .pretendardFont(.bodyMedium) + .foregroundStyle(.neutral300) + .frame(width: 220, height: 274) + .background(.beige50) + .roundedBorder(.beige600, lineWidth: 1) + .padding(.horizontal, 16) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(store.hotBattles) { battle in + HotBattleCardView(battle: battle) + .contentShape(Rectangle()) + .onTapGesture { send(.hotBattleTapped(battle)) } + } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } } } @ViewBuilder - private func bestBattlesSection() -> some View { + private func bestCommentsSection() -> some View { VStack(alignment: .leading, spacing: 12) { - HomeSectionHeader(title: "Best 배틀") { - send(.seeMoreTapped(.bestBattles)) - } + HomeSectionHeader(title: "Best 댓글", onSeeMoreTapped: nil) VStack(spacing: 0) { - ForEach(Array(store.bestBattles.enumerated()), id: \.element.id) { index, battle in - BestBattleCardView(battle: battle) + ForEach(Array(store.bestComments.enumerated()), id: \.offset) { index, comment in + HomeBestCommentRow(comment: comment) .contentShape(Rectangle()) - .onTapGesture { send(.bestBattleTapped(battle)) } - if index < store.bestBattles.count - 1 { + .onTapGesture { send(.bestCommentTapped(comment)) } + if index < store.bestComments.count - 1 { PickeDivider(.beige600) + .padding(.horizontal, 16) } } } - .padding(.horizontal, 16) } } @ViewBuilder - private func todayPickeSection() -> some View { - VStack(alignment: .leading, spacing: 16) { - HomeSectionHeader(title: "오늘의 Pické") { - send(.seeMoreTapped(.todayPicke)) + private func bestBattlesSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + HomeSectionHeader(title: "Best 배틀") { + send(.seeMoreTapped(.bestBattles)) } - VStack(spacing: 16) { - if let quiz = store.currentQuiz { - QuizCardView(question: quiz) - } - if let vote = store.currentVote { - VoteCardView(question: vote) + VStack(spacing: 0) { + ForEach(Array(store.bestBattles.enumerated()), id: \.element.id) { index, battle in + BestBattleCardView(battle: battle) .contentShape(Rectangle()) - .onTapGesture {} + .onTapGesture { send(.bestBattleTapped(battle)) } + if index < store.bestBattles.count - 1 { + PickeDivider(.beige600) + } } } .padding(.horizontal, 16) diff --git a/Projects/Feature/Home/Tests/Sources/HomeTests.swift b/Projects/Feature/Home/Tests/Sources/HomeTests.swift index 9e79b66b..a8762fdf 100644 --- a/Projects/Feature/Home/Tests/Sources/HomeTests.swift +++ b/Projects/Feature/Home/Tests/Sources/HomeTests.swift @@ -53,6 +53,38 @@ struct HomeTests { #expect(store.state.hasUnreadNotification == false) #expect(store.state.shouldShowSkeleton == false) } + + @Test + @MainActor + func homeResponseStoresBestCommentsWithoutMockFallback() async { + let comment = HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + ) + let store = TestStore(initialState: HomeFeature.State()) { + HomeFeature() + } + + await store.send(.inner(.homeResponse(.success(.commentsOnly(comment))))) { + $0.hasLoadedHome = true + $0.bestComments = [comment] + } + + #expect(store.state.hotBattles.isEmpty) + #expect(store.state.hasVisibleHomeContent) + #expect(store.state.shouldShowSkeleton == false) + } + + @Test + func loadedEmptyHomeHasNoVisibleContent() { + var state = HomeFeature.State() + state.hasLoadedHome = true + + #expect(state.shouldShowSkeleton == false) + #expect(state.hasVisibleHomeContent == false) + } } private extension HomeBundle { @@ -65,4 +97,17 @@ private extension HomeBundle { votes: [], newBattles: [] ) + + static func commentsOnly(_ comment: HomeBestComment) -> HomeBundle { + HomeBundle( + newNotice: false, + heroes: [], + hotBattles: [], + bestBattles: [], + bestComments: [comment], + quizzes: [], + votes: [], + newBattles: [] + ) + } } diff --git a/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift b/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift index 2aefadcf..f63af6c8 100644 --- a/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift +++ b/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift @@ -1,6 +1,7 @@ public enum ClassBattleAPI { case detail(classID: Int) case preVote(classID: Int) + case midVote(classID: Int) case ttsComplete(classID: Int) case postVote(classID: Int) case myVotes(classID: Int) @@ -12,6 +13,7 @@ public enum ClassBattleAPI { switch self { case let .detail(id): return "classes/\(id)/battle" case let .preVote(id): return "classes/\(id)/battle/votes/pre" + case let .midVote(id): return "classes/\(id)/battle/votes/mid" case let .ttsComplete(id): return "classes/\(id)/battle/votes/tts-complete" case let .postVote(id): return "classes/\(id)/battle/votes/post" case let .myVotes(id): return "classes/\(id)/battle/votes/me" diff --git a/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift b/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift index 62b20f2d..88e336f9 100644 --- a/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift +++ b/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift @@ -3,6 +3,7 @@ public enum ClassResultAPI { case myReport(classID: Int) case insight(classID: Int) case retryInsight(classID: Int) + case myActivity(classID: Int) public var description: String { switch self { @@ -10,6 +11,7 @@ public enum ClassResultAPI { case let .myReport(id): return "classes/\(id)/result/my-report" case let .insight(id): return "classes/\(id)/result/insight" case let .retryInsight(id): return "classes/\(id)/result/insight/retry" + case let .myActivity(id): return "classes/\(id)/result/my-activity" } } } diff --git a/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift b/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift index ef3c7455..4b1b5209 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift @@ -6,6 +6,7 @@ import PickeNetwork public enum ClassBattleService { case detail(classID: Int) case preVote(classID: Int, optionID: Int) + case midVote(classID: Int, checkpointID: Int, optionID: Int) case ttsComplete(classID: Int) case postVote(classID: Int, optionID: Int) case myVotes(classID: Int) @@ -22,6 +23,7 @@ extension ClassBattleService: PickeDataRequest { switch self { case let .detail(classID): return ClassBattleAPI.detail(classID: classID).description case let .preVote(classID, _): return ClassBattleAPI.preVote(classID: classID).description + case let .midVote(classID, _, _): return ClassBattleAPI.midVote(classID: classID).description case let .ttsComplete(classID): return ClassBattleAPI.ttsComplete(classID: classID).description case let .postVote(classID, _): return ClassBattleAPI.postVote(classID: classID).description case let .myVotes(classID): return ClassBattleAPI.myVotes(classID: classID).description @@ -35,7 +37,7 @@ extension ClassBattleService: PickeDataRequest { public var method: HTTPMethod { switch self { case .detail, .myVotes, .voteStats, .perspectives, .myPerspectives: return .get - case .preVote, .ttsComplete, .postVote, .publishPerspective: return .post + case .preVote, .midVote, .ttsComplete, .postVote, .publishPerspective: return .post } } @@ -43,6 +45,8 @@ extension ClassBattleService: PickeDataRequest { switch self { case let .preVote(_, optionID), let .postVote(_, optionID): return VoteBody(optionId: optionID) + case let .midVote(_, checkpointID, optionID): + return MidVoteBody(checkpointId: checkpointID, optionId: optionID) case let .publishPerspective(_, content): return PerspectiveBody(content: content) case let .perspectives(_, cursor, size, optionID, sort): diff --git a/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift index d46892c6..2c8bccf7 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift @@ -4,6 +4,11 @@ struct VoteBody: Encodable, Sendable { let optionId: Int } +struct MidVoteBody: Encodable, Sendable { + let checkpointId: Int + let optionId: Int +} + struct PerspectiveBody: Encodable, Sendable { let content: String } diff --git a/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift b/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift index d282d4a7..1dc16841 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift @@ -7,6 +7,7 @@ public enum ClassResultService { case myReport(Int) case insight(Int) case retryInsight(Int) + case myActivity(Int, cursor: String?) } extension ClassResultService: PickeDataRequest { @@ -20,17 +21,21 @@ extension ClassResultService: PickeDataRequest { case let .myReport(id): return ClassResultAPI.myReport(classID: id).description case let .insight(id): return ClassResultAPI.insight(classID: id).description case let .retryInsight(id): return ClassResultAPI.retryInsight(classID: id).description + case let .myActivity(id, _): return ClassResultAPI.myActivity(classID: id).description } } public var method: HTTPMethod { switch self { - case .result, .myReport, .insight: return .get + case .result, .myReport, .insight, .myActivity: return .get case .retryInsight: return .post } } public var parameters: (any Encodable & Sendable)? { - nil + switch self { + case let .myActivity(_, cursor): return MyActivityQuery(cursor: cursor) + case .result, .myReport, .insight, .retryInsight: return nil + } } } diff --git a/Projects/Service/APIEndpoint/Sources/Class/Result/Request/ClassResultRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Result/Request/ClassResultRequests.swift new file mode 100644 index 00000000..0b4c0391 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Result/Request/ClassResultRequests.swift @@ -0,0 +1,5 @@ +import Foundation + +struct MyActivityQuery: Encodable, Sendable { + let cursor: String? +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift index 095b46d8..7e2d5446 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift @@ -10,6 +10,7 @@ struct CreateClassBody: Encodable, Sendable { let commentRequired: Bool let ownerParticipates: Bool let ownerDisplayName: String? + let requestKey: String? init(creation: ClassCreation) { name = creation.name @@ -21,6 +22,7 @@ struct CreateClassBody: Encodable, Sendable { commentRequired = creation.requiresComment ownerParticipates = creation.ownerParticipates ownerDisplayName = creation.ownerDisplayName + requestKey = creation.requestKey } } diff --git a/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift index 702eceba..9de53c0b 100644 --- a/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift +++ b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift @@ -43,6 +43,23 @@ public extension View { ) ) } + + /// Figma 줄 높이에 맞춘다. `lineHeightMultiple`은 글자 크기 대비 배수(1.4 = 140%)이고 절대값이 아니다. + /// 줄 사이와 첫 줄 위·마지막 줄 아래에 남는 높이를 나눠 더하므로 배지처럼 배경 높이를 따로 정한 텍스트, + /// 높이가 고정된 컨테이너 안 여러 줄 텍스트에는 쓰지 않는다. + func pretendardFont( + _ textStyle: CustomSizeFont, + lineHeightMultiple: CGFloat + ) -> some View { + let naturalLineHeight = UIFont.pretendardFontFamily( + family: textStyle.fontFamily, + size: textStyle.size + ).lineHeight + let extra = max(0, textStyle.size * lineHeightMultiple - naturalLineHeight) + return pretendardFont(textStyle) + .lineSpacing(extra) + .padding(.vertical, extra / 2) + } } public extension UIFont { diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift index a0c6522b..95e9342e 100644 --- a/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift @@ -45,6 +45,10 @@ public enum CustomAlertStyle: Equatable { case logout case withdraw case suggestTopic + /// 본문 + 취소(왼쪽 밝은)·확인(오른쪽 primary), 취소 문구가 비면 확인 1개. + case notice + /// 본문 + 확인(왼쪽 밝은)·취소(오른쪽 primary). 배경 탭(취소)이 "안내만 닫기"여야 할 때 쓴다. + case noticeReversed } @CasePathable @@ -162,4 +166,60 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .suggestTopic ) } + + // MARK: - 클래스 이용권 안내 (Android 문구) + + static func classTicketRequired() -> CustomAlertState { + notice( + "클래스를 열려면 이용권이 필요해요\n클래스 기능을 먼저 알아보세요!", + confirmTitle: "뒤로가기" + ) + } + + static func classTicketExpired() -> CustomAlertState { + CustomAlertState( + title: "사용 중이던 이용권이 종료되었습니다\n새로운 이용권을 등록해주세요!", + confirmTitle: "등록하기", + cancelTitle: "뒤로가기", + style: .notice + ) + } + + /// 뒤로가기=확인(화면 닫기), 등록하기=취소(안내만 닫기). 배경 탭도 안내만 닫는다. + static func ticketAlreadyRegistered() -> CustomAlertState { + CustomAlertState( + title: "이미 등록된 이용권입니다\n다른 이용권을 등록해주세요!", + confirmTitle: "뒤로가기", + cancelTitle: "등록하기", + style: .noticeReversed + ) + } + + static func ticketInvalidCode() -> CustomAlertState { + notice("코드 번호를 다시 확인해주세요!") + } + + static func ticketRegistrationExpired() -> CustomAlertState { + notice("해당 이용권은 등록기간이 만료되었습니다") + } + + static func ticketRegisterFailed() -> CustomAlertState { + notice("코드 등록이 완료되지 않았습니다\n네트워크 확인 후 한 번 더 시도해주세요") + } + + static func ticketAlreadyActive() -> CustomAlertState { + notice("이미 사용 중인 이용권이 있어요\n만료 후 등록할 수 있어요") + } + + private static func notice( + _ title: String, + confirmTitle: String = "확인" + ) -> CustomAlertState { + CustomAlertState( + title: title, + confirmTitle: confirmTitle, + cancelTitle: "", + style: .notice + ) + } } diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift index 74f9357a..bbbdb81d 100644 --- a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift @@ -65,6 +65,16 @@ extension CustomConfirmationPopup { } } + /// primary 버튼 1개 행. + func pickeSingleButtonRow( + title: String, + action: @escaping () -> Void + ) -> some View { + pickePrimaryButton(title: title, action: action) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + @ViewBuilder private func pickeSecondaryButton( title: String, diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift index c9647f31..743ac997 100644 --- a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift @@ -18,7 +18,7 @@ extension CustomConfirmationPopup { switch style { case .confirmation: return 20 - case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic: + case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic, .notice, .noticeReversed: return 0 } } @@ -40,6 +40,10 @@ extension CustomConfirmationPopup { logoutWithdrawContent case .suggestTopic: suggestTopicContent + case .notice: + noticeContent + case .noticeReversed: + noticeReversedContent } } @@ -58,6 +62,39 @@ extension CustomConfirmationPopup { } } + /// 안내 — 본문 + 취소(왼쪽 밝은)·확인(오른쪽 primary). 취소 문구가 비면 확인 버튼 1개. + @ViewBuilder + var noticeContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + pickeBodyText(title) + + if cancelTitle.isEmpty { + pickeSingleButtonRow(title: confirmTitle, action: onConfirm) + } else { + pickeTwoButtonRow( + leftTitle: cancelTitle, leftAction: onCancel, + rightTitle: confirmTitle, rightAction: onConfirm + ) + } + } + } + } + + /// 안내(반대 배치) — 확인(왼쪽 밝은)·취소(오른쪽 primary). 배경 탭(취소)이 "안내만 닫기"가 되게 한다. + var noticeReversedContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + pickeBodyText(title) + + pickeTwoButtonRow( + leftTitle: confirmTitle, leftAction: onConfirm, + rightTitle: cancelTitle, rightAction: onCancel + ) + } + } + } + /// 주제 제안 — 타이틀 + 본문 + 뒤로가기(왼쪽 밝은) / 제안하기(오른쪽 primary). var suggestTopicContent: some View { pickeAlertCard { diff --git a/Tools/FigmaClassAudit/README.md b/Tools/FigmaClassAudit/README.md index 2eadc6ce..6a2b04fa 100644 --- a/Tools/FigmaClassAudit/README.md +++ b/Tools/FigmaClassAudit/README.md @@ -9,15 +9,19 @@ | 최종 투표 | `13337:57185` | | 의견 | `13384:57726` | | 결과 | `13757:17445` | +| 홈 (375×2010) | `13956:18883` | ## 실행 1. Figma 데스크톱에서 [Pické 디자인 파일](https://www.figma.com/design/6Z49tKXvCCQDd7tyGtvtiI/)을 엽니다. 원본이 보기 전용이면 **Edit file menu → Duplicate to your drafts**로 개인 사본을 열어 주세요. 노드 ID는 사본에서도 유지됩니다. 2. **Plugins → Development → Import plugin from manifest…**에서 이 폴더의 `manifest.json`을 선택합니다. 3. **Plugins → Development → Picke Class Design Audit**를 실행합니다. -4. 각 행의 **PNG + JSON** 또는 **전체 ZIP 내려받기**를 누릅니다. +4. 각 행의 **PNG + JSON** 또는 **전체 ZIP 내려받기**를 누릅니다. 단일 화면도 PNG와 JSON을 하나의 ZIP으로 저장합니다. `13956:18883`은 새로 전달된 홈 프레임입니다. 화면 내용은 내보낸 PNG·JSON으로 확인합니다. + 업데이트된 링크의 `2027:2` 아래 화면은 **업데이트된 화면 찾기**에서 이름으로 검색한 뒤 해당 행의 **PNG + JSON**을 누릅니다. 검색 결과는 최대 100개이며 프레임·섹션·컴포넌트와 상위 레이어 경로를 표시합니다. 5. Device Hub에서 앱 화면을 PNG로 캡처한 뒤 **앱 화면 대조**에서 해당 노드와 캡처를 선택합니다. **차이 이미지 만들기**를 누르면 빨간색으로 다른 픽셀과 비율을 보여 주며, 결과 PNG를 내려받을 수 있습니다. 화면만 잘라낸 같은 크기의 캡처를 사용해야 정확합니다. 내보낸 JSON에는 각 하위 노드의 이름·종류·좌표·크기·채우기·텍스트/글꼴 등 읽을 수 있는 속성이 포함됩니다. 노드가 현재 파일에 없으면 해당 ID의 오류가 표시됩니다. 비교는 기준 PNG 크기에 앱 캡처를 맞추고 RGB 채널 최대 차이 30을 넘는 픽셀을 표시합니다. 캡처 크기와 화면 상태가 다르면 수치가 높아질 수 있으므로 차이 이미지를 함께 확인하세요. 이미지와 JSON은 로컬 다운로드만 하며, 네트워크 요청이나 문서 변경은 하지 않습니다. 개발 플러그인으로 사용하므로 게시용 플러그인 ID와 빌드 도구가 필요하지 않습니다. 2026-10-27에 편집 가능한 개인 사본에서 5개 노드를 내보냈고, `picke-class-figma-audit-final.zip`의 PNG 5개·JSON 5개를 `unzip -t`로 확인했습니다. + +2026-11-01에 Pické 디자인 원본에서 개발 플러그인을 실행해 클래스·홈 6개 프레임의 PNG·JSON 12개를 로컬 `~/Downloads/picke-class-figma-audit.zip`에 저장했습니다. `2027:2`의 `클래스 참여 · iPhone 18` 프레임도 로컬 `~/Downloads/picke-class-10052-11290.zip`으로 추출했습니다. 전체 레이어 JSON은 공개 GitHub 저장소에 올리지 않습니다. diff --git a/Tools/FigmaClassAudit/code.js b/Tools/FigmaClassAudit/code.js index 4daaf978..dea06cd7 100644 --- a/Tools/FigmaClassAudit/code.js +++ b/Tools/FigmaClassAudit/code.js @@ -5,7 +5,10 @@ const targets = [ { id: "13337:57185", label: "최종 투표" }, { id: "13384:57726", label: "의견" }, { id: "13757:17445", label: "결과" }, + { id: "13956:18883", label: "홈" }, + { id: "12378:23897", label: "클래스 탭" }, ]; +const sourceNodeId = "2027:2"; figma.showUI(__html__, { width: 500, height: 760, themeColors: true }); @@ -92,13 +95,44 @@ async function exportTarget(target) { figma.ui.onmessage = async (message) => { if (message && message.type === "init") { - figma.ui.postMessage({ type: "ready", fileName: figma.root.name, targets }); + figma.ui.postMessage({ type: "ready", fileName: figma.root.name, targets, sourceNodeId }); + return; + } + if (message && message.type === "search") { + try { + const query = String(message.query || "").trim().toLocaleLowerCase(); + if (!query) throw new Error("검색어를 입력하세요."); + const source = await figma.getNodeByIdAsync(sourceNodeId); + if (!source || typeof source.findAll !== "function") { + throw new Error(`${sourceNodeId} 노드에서 하위 화면을 찾을 수 없습니다.`); + } + if (source.type === "PAGE") await source.loadAsync(); + const matches = source.findAll(node => + (node.type === "FRAME" || node.type === "SECTION" || node.type === "COMPONENT") && + node.name.toLocaleLowerCase().includes(query) + ).slice(0, 100).map(node => { + const ancestors = []; + let parent = node.parent; + while (parent && parent !== source) { + ancestors.unshift(parent.name); + parent = parent.parent; + } + return { + id: node.id, name: node.name, type: node.type, ancestors, + width: property(node, "width"), height: property(node, "height"), + }; + }); + figma.ui.postMessage({ type: "matches", query, matches }); + } catch (error) { + figma.ui.postMessage({ type: "error", message: String(error && error.message ? error.message : error) }); + } return; } if (!message || (message.type !== "export-one" && message.type !== "export-all")) return; const selected = message.type === "export-all" ? targets - : targets.filter((target) => target.id === message.id); + : [{ id: String(message.id || ""), label: String(message.label || message.id || "") }] + .filter((target) => /^\d+:\d+$/.test(target.id)); if (selected.length === 0) { figma.ui.postMessage({ type: "error", message: "지정되지 않은 노드입니다." }); return; diff --git a/Tools/FigmaClassAudit/ui.html b/Tools/FigmaClassAudit/ui.html index 00c8aa3a..ce5e4614 100644 --- a/Tools/FigmaClassAudit/ui.html +++ b/Tools/FigmaClassAudit/ui.html @@ -20,6 +20,8 @@ #status { min-height: 44px; margin-top: 14px; font-size: 12px; white-space: pre-wrap; } .error { color: #c33; } .compare { margin-top: 20px; padding: 12px; border: 1px solid var(--figma-color-border, #ddd); border-radius: 6px; } + .search { margin-top: 20px; padding: 12px; border: 1px solid var(--figma-color-border, #ddd); border-radius: 6px; } + .search input { box-sizing: border-box; width: 100%; margin: 8px 0; padding: 8px; } .compare label { display: block; margin: 8px 0; font-size: 12px; } .compare select, .compare input { max-width: 100%; margin-top: 5px; } #diff { display: none; width: 100%; margin-top: 10px; image-rendering: auto; } @@ -27,9 +29,16 @@

클래스 확정 시안 내보내기

-

열린 Figma 파일: 확인 중…
지정한 5개 노드만 읽습니다. 파일을 수정하거나 외부로 전송하지 않습니다.

+

열린 Figma 파일: 확인 중…
지정된 6개 노드와 검색한 화면을 읽습니다. 파일을 수정하거나 외부로 전송하지 않습니다.

+
앱 화면 대조

Device Hub에서 화면 부분만 잘라낸 PNG를 선택하세요. 상태 표시줄과 기기 테두리는 제외해야 합니다. 픽셀 차이는 화면 상태와 글꼴 렌더링의 영향도 받습니다.

@@ -47,6 +56,9 @@

클래스 확정 시안 내보내기

const status = document.getElementById('status'); const encoder = new TextEncoder(); const compareNode = document.getElementById('compare-node'); + const searchButton = document.getElementById('search-button'); + const query = document.getElementById('query'); + const matches = document.getElementById('matches'); const compareFile = document.getElementById('compare-file'); const compareButton = document.getElementById('compare-button'); const compareResult = document.getElementById('compare-result'); @@ -208,7 +220,33 @@

클래스 확정 시안 내보내기

compareNode.appendChild(option); } allButton.disabled = false; + searchButton.disabled = false; compareButton.disabled = false; + document.getElementById('source').textContent = '링크의 ' + message.sourceNodeId + ' 노드에서 이름을 검색합니다.'; + } else if (message.type === 'matches') { + setBusy(false); + matches.replaceChildren(); + if (!message.matches.length) matches.textContent = '일치하는 프레임이 없습니다.'; + for (const match of message.matches) { + const row = document.createElement('div'); + row.className = 'row'; + const description = document.createElement('div'); + description.className = 'node'; + const title = document.createElement('strong'); + title.textContent = match.name; + const detail = document.createElement('small'); + detail.textContent = `${match.id} · ${match.type} · ${match.width}×${match.height}`; + if (match.ancestors && match.ancestors.length) { + detail.textContent += ` · ${match.ancestors.join(' / ')}`; + } + description.append(title, detail); + const button = document.createElement('button'); + button.textContent = 'PNG + JSON'; + button.onclick = () => parent.postMessage({ pluginMessage: { type: 'export-one', id: match.id, label: match.name } }, '*'); + row.append(description, button); + matches.appendChild(row); + } + status.textContent = message.matches.length + '개 프레임을 찾았습니다.'; } else if (message.type === 'busy') { setBusy(true); status.className = ''; @@ -228,12 +266,11 @@

클래스 확정 시안 내보내기

return; } const entries = asEntries(message.results); - if (message.bundle && entries.length) { - download(zip(entries), 'picke-class-figma-audit.zip', 'application/zip'); - } else { - for (const entry of entries) { - download(entry.data, entry.name, entry.name.endsWith('.png') ? 'image/png' : 'application/json'); - } + if (entries.length) { + const name = message.bundle + ? 'picke-class-figma-audit.zip' + : fileStem(message.results[0]) + '.zip'; + download(zip(entries), name, 'application/zip'); } status.className = message.errors.length ? 'error' : ''; status.textContent = message.results.length + '개 내보냄' + (message.errors.length @@ -249,6 +286,12 @@

클래스 확정 시안 내보내기

}; allButton.onclick = () => parent.postMessage({ pluginMessage: { type: 'export-all' } }, '*'); + searchButton.onclick = () => { + setBusy(true); + status.textContent = '프레임을 찾고 있습니다…'; + parent.postMessage({ pluginMessage: { type: 'search', query: query.value } }, '*'); + }; + query.onkeydown = event => { if (event.key === 'Enter') searchButton.click(); }; compareButton.onclick = () => { if (!compareFile.files.length) { compareResult.textContent = '앱 캡처 PNG를 먼저 선택하세요.'; diff --git a/docs/class-android-ios-parity-20261029.md b/docs/class-android-ios-parity-20261029.md new file mode 100644 index 00000000..b092efa4 --- /dev/null +++ b/docs/class-android-ios-parity-20261029.md @@ -0,0 +1,64 @@ +# Class Android/iOS API 대조 (2026-10-29) + +기준: [Android `develop` `05c14af`](https://github.com/SWYP-Find/Picke-android/tree/05c14af23f0246e0e0f6d49e835dfb5151828696), [서버 클래스 API 명세 `1dc6db2`](https://github.com/SWYP-Find/Server/blob/1dc6db2c5de786bd7ba51ec3a052724102010c27/docs/api-specs/class-api.md), 이 저장소의 현재 작업 브랜치. Android의 열린 PR은 없으며 [#135 AI 토론](https://github.com/SWYP-Find/Picke-android/pull/135), [#137 결과](https://github.com/SWYP-Find/Picke-android/pull/137), [#139 후속 수정](https://github.com/SWYP-Find/Picke-android/pull/139)이 머지된 상태다. 코드와 명세의 정적 대조이며 인증된 실서버 호출이나 화면 일치의 증거는 아니다. + +## Endpoint + +Android `ClassroomApi` 30개, `VoteApi`의 클래스 전용 2개, `PerspectiveApi`의 클래스 전용 3개에 있는 클래스 관련 endpoint 35개 모두 iOS에 대응 구현이 있다. iOS 경로는 `Projects/Service/APIEndpoint/Sources/Class/`의 Ticket, Room, Member, Battle, TopicDraft, Recommendation 서비스에 있다. 공통 요청의 경로·메서드·주요 body/query 필드는 일치한다. + +| 영역 | Android | iOS와 서버 명세 대조 | +| --- | --- | --- | +| 이용권·클래스·가입·멤버 | `ClassroomApi.kt` | 19개 endpoint 대응. 클래스 생성의 선택적 `ownerDisplayName`·`requestKey`, 초안 제안의 선택적 `filter`는 iOS가 추가 지원한다. | +| 투표·의견 | `ClassroomApi.kt`, `VoteApi.kt`, `PerspectiveApi.kt` | 8개 endpoint 대응. iOS는 중간 투표와 투표 이력의 `midVotes`를 추가 지원한다. | +| Android에 아직 없는 서버 기능 | 해당 API 없음 | iOS는 초안 조회·직접 수정 검증과 중간 투표를 추가 구현했다. Android에도 AI 토론·답글·재시도, 결과·내 리포트·인사이트·재시도 API와 ViewModel 연결이 머지됐다. | + +## 계약 차이와 처리 + +| 항목 | 확인 결과 | +| --- | --- | +| 클래스 의견 작성자 `userTag` | 서버 응답은 `null`일 수 있고 Android DTO도 nullable이다. iOS DTO를 optional로 수정하고 도메인 매핑에서 빈 문자열로 처리했다. | +| 내 의견 조회 | Android `PerspectiveApi.kt`는 단일 DTO를 반환하도록 선언했지만 서버 명세는 배열이다. iOS 배열 파싱을 유지한다. | +| 배틀 추천 선택지 | Android DTO의 `label/rationale/philosopher*`는 구형 형태다. 서버와 iOS는 `optionId/position/title/stance/representative/imageUrl`을 사용한다. | +| TTS 완료 호출 | Android는 최종 투표 제출 직전에 호출한다. iOS는 재생 완료 단계에서 호출하며, 서버의 재생 완료 시 호출 계약에 부합한다. | +| 필수 의견 삭제 | 서버는 클래스 설정에 따라 삭제를 거부할 수 있다. iOS는 실패 후 화면을 닫던 동작을 수정해 오류 알림을 표시한다. | +| 초대코드 실패 표시 | Android는 미분류 요청 실패를 일반 토스트로 표시한다. iOS 실기동에서는 인증 재시도 오류의 내부 타입 문자열이 코드 입력 화면에 그대로 노출됐다. `ClassError.unknown` 표시 문구를 일반 요청 오류로 바꿨다. | +| 401 재시도 | Android `TokenAuthenticator`는 재발급 뒤에도 401이면 두 번째 응답에서 재시도를 멈춘다. iOS는 실제 초대코드 조회에서 Alamofire `excessiveRefresh`가 발생했다. 서버가 새 토큰을 거부하는지, 토큰 저장·갱신 경로가 어긋나는지는 추가 인증 증거가 필요하다. | + +최신 Android AI 토론·결과 ViewModel은 실제 Repository를 호출한다. Android 결과 카드의 0명 상태는 `아직 참여 없음`과 중립색 도넛을 사용하고, 비율은 응답의 인원수로 계산한다. iOS 결과 화면도 인원수 기반 비율, 0명 문구·중립색 도넛, 참여 설명 문구와 6pt 막대로 맞췄다. 별도 Chrome 탭의 최신 Figma 파일에서 결과 프레임 `13757:17445`를 2026-10-29에 다시 내보내 [`docs/evidence/class-result-figma-20261029.png`](evidence/class-result-figma-20261029.png)에 보관했다. PNG SHA-256 `2720fafbbfb6b68f5d22eaae0cc57851c07e9a636007064eacc7e77954cf8d15`는 2026-10-27 플러그인 추출본과 완전히 같았다. 따라서 이 결과 프레임 자체의 시안은 바뀌지 않았다. 사용자가 준 `2027:2`는 이 파일의 와이어프레임/Hifi 페이지를 열었으며, 그 아래 다른 변경 화면의 전체 추출·대조는 남아 있다. + +## 이번 실행 검증 + +- iPhone 18 Pro (`68DAE7F3-C265-46F4-9FEE-71140108E210`) 대상 `tuist test ClassDomain` 성공. Foundation Models 기기 전용 테스트 4개는 시뮬레이터에서 건너뛰었다. +- 같은 대상 `tuist test Chat`, `tuist test PickeNetwork`, 최종 `tuist build Picke-Stage` 성공. 빌드된 Stage 앱을 시뮬레이터에 설치하고 실행해 클래스 진입 화면까지 확인했다. +- Device Hub에서 앱 홈과 클래스 진입 화면은 표시됐다. `내 클래스` 목록은 재시도 후에도 오류 화면이었고, 앱 로그에서 `GET /api/v1/classes`의 서버 `AUTH_401`을 확인했다. 따라서 제공된 테스트 클래스·초대코드·이용권 코드의 인증된 실제 응답은 확인하지 못했다. +- 최종 설치 앱을 iPhone 18 Pro Device Hub에서 다시 조작했다. `내 클래스`는 오류 화면이었고, `클래스 참여`에서 테스트 초대코드 여섯 글자 입력 및 활성화된 버튼까지 시각 확인했다. 버튼을 누른 실제 `GET /api/v1/classes/join/preview?inviteCode=`는 서버 `AUTH_401`로 끝났으며 화면에는 `AuthenticationError.excessiveRefresh`가 포함된 내부 오류가 표시됐다. +- 오류 문구 수정 후 `tuist test ClassDomain`과 `tuist build Picke-Stage`가 성공했다. 새 Stage 앱을 같은 iPhone 18 Pro에 설치·실행해 동일한 초대코드를 다시 요청했다. 응답 실패는 계속됐지만, 화면에는 `클래스 요청을 처리하지 못했습니다. 다시 시도해 주세요.`만 표시되고 내부 오류 문자열은 사라진 것을 Device Hub 접근성 트리와 스크린샷으로 확인했다. +- Android `ClassJoinCodeStep.kt`와 iOS `ClassJoinView.swift`의 코드 입력 UI는 모두 6칸, 대문자 ASCII 입력, 코드 검증 후 이름 입력의 순서다. 실제 Android 실행 화면이나 최신 Figma 캡처가 없어 색·간격·폰트의 일치 여부는 판정하지 않았다. +- Figma 개발 플러그인에는 `2027:2` 하위 화면 검색과 PNG/JSON 내보내기를 추가했고 JavaScript 문법 검사를 통과했다. 별도 Chrome 탭에서 Figma 로그인을 완료하고 결과 프레임 PNG를 다시 내보내 기존 플러그인 PNG와 SHA-256 일치를 확인했다. 새 디자인 전체에 대한 플러그인 실행과 앱 화면의 픽셀 대조는 아직 실행하지 못했다. 사용 중인 원래 탭은 건드리지 않았다. +- API 로그 진단 중 인증 헤더가 그대로 기록되는 문제를 발견해 향후 로그에서 인증 헤더와 인증 API 본문을 가리도록 수정했다. `PickeNetwork` 테스트가 통과했다. +- 2026-10-30에 전체 워크스페이스를 재생성한 뒤 단일 iPhone 18 Pro 목적지로 `xcodebuild -workspace Picke.xcworkspace -scheme Picke-Stage ... build`를 실행해 종료 코드 0을 확인했다. `Class`와 `Picke-Stage`의 `build-for-testing`도 종료 코드 0으로 성공했다. +- 새 Stage 앱을 iPhone 18 Pro에 설치·실행해 다시 확인했다. `GET /api/v1/classes`와 `GET /api/v1/classes/join/preview?inviteCode=`가 모두 `dev.picke.store`에서 `401 / AUTH_401`을 반환했다. 초대코드 입력 화면에는 일반 오류 문구만 표시되고 인증 헤더는 로그에서 ``로 가려졌다. 이 계정의 클래스 상세·가입·이용권 등록은 진행할 수 없었다. +- `ClassReportTests` 실행은 5분 이상 결과가 없어 중단했다. 한 테스트만 지정한 실행은 종료 코드 0이었으나 결과 번들의 `totalTestCount=0`이라 통과로 간주하지 않는다. 결과 화면 캡처 테스트는 한 번 건너뛰기(`PICKE_CLASS_VISUAL_AUDIT`가 테스트 호스트에 미전달)를 확인했고, 환경변수를 넣은 재실행은 4분 넘게 완료되지 않아 중단했다. 테스트 타깃 컴파일은 성공했지만 실제 캡처와 Figma 픽셀 비교는 미완료다. +- 2026-10-30에 추가 전달된 Figma 노드 `13956:18883`을 감사 플러그인의 기본 내보내기 대상에 추가했다. 별도 Chrome 창의 Figma 내보내기로 `홈` 프레임(375×2010)의 2배율 PNG [`docs/evidence/home-figma-13956-18883-20261030.png`](evidence/home-figma-13956-18883-20261030.png)를 확보했다(750×4020, SHA-256 `6d6741a75f6538fd5d79fef7b193ad89686ce25ffc97702d93d6db0a3f82e99e`). 개발 플러그인은 등록됐지만 Figma Desktop 공유 창이 가로막아 실행·JSON 추출은 확인하지 못했다. 사용 중인 원래 탭과 FESTICORE 창은 변경하지 않았다. +- 동일한 iPhone 18 Pro 설치 앱 로그에서 클래스 API뿐 아니라 출석 체크·주간 출석·읽지 않은 알림도 `AUTH_401`로 끝났다. 따라서 클래스 전용 endpoint 문제보다 현재 저장된 로그인 세션 또는 공통 인증 갱신의 문제일 가능성이 크다. 갱신 요청의 최종 HTTP 응답은 확인되지 않았으므로 원인은 확정하지 않았다. +- iOS 인증 코드와 Android `TokenAuthenticator`·`AuthInterceptor`를 다시 정적 대조했다. Bearer 주입, `POST /api/v1/auth/refresh`와 `X-Refresh-Token`, 새 토큰 저장·공유 인터셉터 갱신 경로에 규약 불일치는 발견하지 못했다. 새 CoreSimulator 로그 조회는 샌드박스 접근 오류로 실패했다. 기존 요청 401 → 갱신 HTTP 상태 → 갱신 뒤 재요청 상태를 비밀값 없이 확보하기 전까지 특정 코드·서버·토큰을 원인으로 단정하지 않는다. +- Device Hub의 iPhone 18 Pro에서 이전에 실제 홈 화면의 헤더·대표 배틀·Best 배틀·하단 탭을 확인했다. 이후 같은 시뮬레이터의 전면 앱이 FESTICORE Design Kit으로 바뀌어 현재 Pické 화면 캡처를 얻지 못했다. 다른 작업을 방해하지 않기 위해 앱 전환·재설치는 하지 않았다. 따라서 새 Figma PNG와 iOS의 픽셀 비교·시각 일치 판정은 미완료다. + +## Figma 홈 `13956:18883` 정적 대조 (2026-10-30) + +Figma PNG에는 대표 배틀, `지금 뜨는 배틀`, `Best 배틀`, `Best 댓글`, `새로운 배틀`이 순서대로 있다. 현재 iOS `HomeView`는 대표 배틀·뜨는 배틀·Best 배틀·조건부 `오늘의 Pické`·새로운 배틀을 표시하고, 광고를 삽입한다. Android `05c14af`의 `HomeScreen`은 뜨는 배틀이 0건이어도 제목과 빈 카드를 표시하고, 광고를 삽입하며, `오늘의 Pické`는 홈에서 표시하지 않도록 주석 처리했다. Android에도 `Best 댓글` 홈 섹션은 없다. iOS `GET /api/v1/home` 응답 모델과 Android 홈 화면 모델에는 Best 댓글 목록 필드가 없어 실제 데이터 기반으로 시안의 이 섹션을 그릴 수 없다. 화면 데이터가 다른 상태이므로 카드 글자·사진의 픽셀 차이는 UI 구현 결함으로 판정하지 않았다. + +## 전달 상태 + +2026-10-30 GitHub에서 [PR #78](https://github.com/SWYP-Find/Picke-iOS/pull/78)의 `develop` 머지(`1b9ed1d`)와 [PR #79](https://github.com/SWYP-Find/Picke-iOS/pull/79)의 열린 Draft 상태를 다시 확인했다. 현재 작업 트리의 추가 수정은 #79에 반영되지 않았다. 인증 API 성공, Figma 화면 실측 비교, 전체 테스트 실행 및 TestFlight 업로드가 끝나지 않아 이번 추가 작업을 배포 완료로 표시하지 않는다. Claude CLI 계획 검토와 구현 후 점검은 각각 시간 초과로 종료돼 교차 검토 승인 증거도 없다. + +## 2026-11-01 재검증과 최신 디자인 추출 + +- Figma Desktop에서 이 저장소의 `Picke Class Design Audit` 개발 플러그인을 실제 실행했다. 지정된 클래스 5개와 홈 1개의 PNG 및 레이어 JSON 12개 파일을 로컬 `~/Downloads/picke-class-figma-audit.zip`으로 내보냈다. `2027:2` 아래에서 `클래스 참여 · iPhone 18` 프레임 `10052:11290`(402×874)을 검색해 PNG와 JSON을 로컬 `~/Downloads/picke-class-10052-11290.zip`에 저장했다. 전체 레이어 JSON은 공개 GitHub 저장소의 PR에 포함하지 않는다. 단일 화면도 한 ZIP으로 내려받도록 플러그인을 수정해 Figma Desktop의 연속 다운로드 차단을 피했다. +- 추출한 iPhone 18 참여 프레임은 코드 입력을 한 개의 테두리 상자로 그리고 코드 찾기 안내 카드를 둔다. 최신 Android `ClassJoinCodeStep`과 iOS `ClassJoinView`는 6칸 코드 입력을 사용한다. 이 프레임에는 두 개의 하단 CTA가 겹쳐 보여 확정 화면인지 판단할 추가 맥락이 필요하다. 따라서 이 프레임 하나만으로 기존 양 플랫폼의 입력 흐름을 변경하지 않았다. +- 최신 Android `develop` `34448b4`는 홈의 `Best 댓글` 섹션과 nullable `bestComments` 응답 필드를 추가했다. 따라서 위 2026-10-30 대조의 “Android에도 Best 댓글 홈 섹션은 없다”는 문장은 현재 기준으로 유효하지 않다. 서버 `a08c30f`의 `HomeResponse`와 홈 API 명세에는 해당 필드가 없으므로 실데이터 표시 여부는 API 계약 업데이트가 필요하다. 최신 Android 홈에는 `오늘의 Pické` 섹션이 없고, 뜨는 배틀이 0건이어도 빈 상태 카드가 표시된다. +- iPhone 18 Pro 시뮬레이터의 기존 `io.Picke.co` 앱을 실행해 실제 로그인된 홈과 광고 팝업을 캡처했다. Device Hub는 `cgWindowNotFound`로 연결되지 않아 팝업을 닫은 화면 및 클래스 화면의 새 픽셀 대조를 아직 완료하지 못했다. 이 캡처는 새 코드가 빌드·설치된 증거가 아니다. +- 코드 리뷰에서 이용권 코드가 GET 쿼리의 전체 URL 로그에 남는 경로를 확인했다. 네트워크 로그는 URL 경로만 기록하고 인증·클래스·이용권 API의 요청/응답 본문을 가리도록 수정했다. 미분류 내부 오류는 일반 문구로 표시하고, 앱이 명시적으로 만든 사용자 안내는 별도 `userMessage` 타입으로 유지한다. +- `xcodebuild -workspace Picke.xcworkspace -scheme Picke-Stage -configuration Stage -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build -quiet`가 종료 코드 0으로 성공했다. 새 `io.Picke.co` 앱 번들을 iPhone 18 Pro에 설치·실행했고 스플래시 뒤 SNS 로그인 화면으로 이동하는 것을 `simctl` 스크린샷으로 확인했다. 이전 로그인 세션이 유지되지 않아 이 실행에서는 홈·클래스·이용권의 인증된 API와 새 UI를 검증하지 못했다. Device Hub 재연결도 화면 캡처 오류 `-10005`로 실패했다. +- Home Stage 빌드가 성공했고 HomeDomain `HomeRepositoryTests` 4/4, Home 테스트 5/5가 실제 xcresult에서 통과했다. ClassDomain의 `ClassBattleAPIContractTests`와 `ClassErrorMappingTests` 9/9, `ClassMemberAPITests` 3/3, Class `ClassReportTests` 12/12도 통과했다. 첫 ClassReport 실행에서 비율 기대값이 잘못된 테스트 1건을 수정한 뒤 재실행해 12/12를 확인했다. +- Claude CLI 구현 후 교차 점검은 샌드박스 DNS 실패 후 네트워크 제한 밖에서 재시도했으나 180초 시간 초과로 종료됐다. 승인 또는 코드 검토 완료로 간주하지 않는다. PR #79의 새 변경분은 아직 원격 CI를 통과한 상태가 아니며, 인증된 실화면·API 확인과 Figma 픽셀 비교도 남아 있다. diff --git a/docs/evidence/class-result-figma-20261029.png b/docs/evidence/class-result-figma-20261029.png new file mode 100644 index 00000000..35b8d542 Binary files /dev/null and b/docs/evidence/class-result-figma-20261029.png differ diff --git a/docs/evidence/home-figma-13956-18883-20261030.png b/docs/evidence/home-figma-13956-18883-20261030.png new file mode 100644 index 00000000..32cec15a Binary files /dev/null and b/docs/evidence/home-figma-13956-18883-20261030.png differ