diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift index 411c56f7..5e8d6d7a 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -634,6 +634,16 @@ private extension ClassCoordinator { state.routes.push(.setting(.init(battle: battle, instanceID: uuid()))) return .none + case let .routeAction(routeID, action: .recommend(.delegate(.previewRequested(battle)))): + guard routeID == state.routes.count - 1, + case .recommend? = state.routes.last?.screen + else { return .none } + state.routes.push(.battlePreview(.init(battle: battle))) + return .none + + case .routeAction(_, action: .battlePreview(.delegate(.dismiss))): + return .send(.view(.backAction)) + case let .routeAction(_, action: .recommend(.delegate(.selectAIQuestion(question, filter)))): state.routes.push(.setting(.init(aiQuestion: question, filter: filter, instanceID: uuid()))) return .none @@ -1229,6 +1239,7 @@ extension ClassCoordinator { case startMethod(ClassStartMethodFeature) case topic(ClassTopicFeature) case recommend(ClassRecommendFeature) + case battlePreview(ClassBattlePreviewFeature) case setting(ClassSettingFeature) case share(ClassShareFeature) case ownerName(ClassJoinFeature) diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift index f348120c..1226fdcf 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift @@ -40,6 +40,9 @@ public struct ClassCoordinatorView: View { case let .recommend(recommendStore): ClassRecommendView(store: recommendStore) + case let .battlePreview(previewStore): + ClassBattlePreviewView(store: previewStore) + case let .setting(settingStore): ClassSettingView(store: settingStore) diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift index e04df347..104395a2 100644 --- a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift +++ b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift @@ -116,6 +116,8 @@ public struct MainTabCoordinator { } @Dependency(\.analyticsUseCase) private var analyticsUseCase + @Dependency(\.battleUseCase) private var previewBattleUseCase + @Dependency(\.audioPlayer) private var previewAudioPlayer @Dependency(\.classMockChatSession) private var classMockChatSession @Dependency(\.classMockAudioPlayer) private var classMockAudioPlayer @@ -131,6 +133,11 @@ public struct MainTabCoordinator { } Scope(state: \.classState, action: \.classroom) { ClassCoordinator() + .dependency(\.classBattlePreviewDependencies, .init( + battleUseCase: previewBattleUseCase, + audioPlayer: previewAudioPlayer, + analyticsUseCase: analyticsUseCase + )) .dependency(\.battleUseCase, classMockChatSession) .dependency(\.perspectiveUseCase, classMockChatSession) .dependency(\.commentUseCase, classMockChatSession) diff --git a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift index f0b02dac..4fc83e09 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift @@ -16,6 +16,37 @@ import Testing @MainActor struct ClassCoordinatorTests { + @Test + func 추천_배틀_미리듣기는_push로_열고_뒤로가면_추천_목록으로_돌아온다() async { + let battle = ClassBattleSummary.mocks[0] + var state = ClassCoordinator.State() + state.routes.push(.recommend(.init(filter: .init()))) + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction( + id: 1, + action: .recommend(.delegate(.previewRequested(battle))) + ))) + #expect(store.state.routes.count == 3) + guard case let .battlePreview(preview)? = store.state.routes.last?.screen else { + Issue.record("미리듣기 화면이 push되지 않았습니다.") + return + } + #expect(preview.battle.id == battle.id) + + await store.send(.router(.routeAction( + id: 2, + action: .battlePreview(.delegate(.dismiss)) + ))) + await store.receive(\.view.backAction) + #expect(store.state.routes.count == 2) + guard case .recommend? = store.state.routes.last?.screen else { + Issue.record("뒤로 가기 후 추천 목록이 복원되지 않았습니다.") + return + } + } + @Test func AI_투표_등장_이벤트는_현재_화면_인스턴스에만_전달한다() { let room = ClassRoom.mockClosedSociety diff --git a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift index c46f6e1c..cfd9f92a 100644 --- a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift @@ -2136,7 +2136,7 @@ final class ClassFlowVisualAuditTests: XCTestCase { XCTAssertTrue(initialText.contains("선택한조건")) XCTAssertEqual(store.conditionTitles.count, keyword.isEmpty ? 3 : 4) if !keyword.isEmpty { - XCTAssertTrue(initialText.contains("AI와인간사이의윤리와사상에대해서")) + XCTAssertEqual(store.conditionTitles.first, topic) } if mode == .aiQuestions { @@ -2200,6 +2200,96 @@ final class ClassFlowVisualAuditTests: XCTestCase { } } + @MainActor + func testClassBattlePreviewReferenceCapture() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to verify preview layout") + } + let battle = ClassBattleSummary( + id: 901, + title: "인간은 본래 선한가, 악한가?", + summary: "", + philosopherA: "플라톤", + optionATitle: "선하다", + philosopherB: "사르트르", + optionBTitle: "악하다", + category: .philosophy, + level: .high, + audioDuration: 60, + viewCount: 0 + ) + var state = ClassBattlePreviewFeature.State(battle: battle) + state.isActive = true + state.isAudioReady = true + state.isPlaying = true + state.viewState = .loaded + state.currentTime = 13 + state.scenario = BattleScenario( + battleId: battle.id, + title: battle.title, + philosophers: [ + .init(label: "A", name: "플라톤", stance: "", imageUrl: ""), + .init(label: "B", name: "사르트르", stance: "", imageUrl: ""), + ], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: [:], + nodes: [ScenarioNode( + nodeId: 1, + nodeName: "START", + audioDuration: 60, + autoNextNodeId: nil, + scripts: [ + ScenarioScript( + scriptId: 1, + startTimeMs: 0, + speakerType: .a, + speakerName: "플라톤", + text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다." + ), + ScenarioScript( + scriptId: 2, + startTimeMs: 5000, + speakerType: .b, + speakerName: "사르트르", + text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다." + ), + ScenarioScript( + scriptId: 3, + startTimeMs: 10000, + speakerType: .a, + speakerName: "플라톤", + text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다." + ), + ScenarioScript( + scriptId: 4, + startTimeMs: 12000, + speakerType: .b, + speakerName: "사르트르", + text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다." + ), + ], + interactiveOptions: [] + )] + ) + let store = Store(initialState: state) { + ClassBattlePreviewFeature() + .dependency(\.classBattlePreviewDependencies.audioPlayer, DefaultAudioPlayerImpl()) + .dependency(\.classBattlePreviewDependencies.analyticsUseCase, .testValue) + } + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: ClassBattlePreviewView(store: store)) + window.frame = CGRect(x: 0, y: 0, width: 375, height: 812) + window.makeKeyAndVisible() + defer { window.isHidden = true } + let image = try await saveClassAudit(window: window, name: "class-battle-preview-375") + XCTAssertEqual(image.size, CGSize(width: 375, height: 812)) + let text = try recognizedAuditText(in: image, name: "class-battle-preview-375") + XCTAssertTrue(text.contains("1분미리듣기중"), "Preview badge missing from rendered capture: \(text)") + } + @MainActor private func assertRecommendCTA( in image: UIImage, diff --git a/Projects/App/Tests/Sources/CommentFinalDesignTests.swift b/Projects/App/Tests/Sources/CommentFinalDesignTests.swift index 271bdc17..97387ee1 100644 --- a/Projects/App/Tests/Sources/CommentFinalDesignTests.swift +++ b/Projects/App/Tests/Sources/CommentFinalDesignTests.swift @@ -106,10 +106,13 @@ final class CommentFinalDesignTests: XCTestCase { optionId: 1 ) let perspective = try XCTUnwrap(createdPerspective) + let longReply = (1 ... 8).map { + "\($0)번째 근거를 비교하며 충분한 설명인지 다시 살펴봅니다." + }.joined(separator: "\n") let replyTexts = [ "첫 번째 답글입니다. 부모 글에서 나온 주장에 동의합니다.", - "두 번째 답글은 다른 근거를 덧붙입니다. 충분한 설명이 있는지 살펴봐야 합니다.", - "세 번째 답글입니다. 의견을 비교한 뒤 다시 판단해 보고 싶어요.", + longReply, + "세 번째 답글입니다.", ] let perspectiveAPI: any PerspectiveInterface = session for text in replyTexts { @@ -169,10 +172,25 @@ final class CommentFinalDesignTests: XCTestCase { replyStore.send(.binding(.set(\.replyText, "답글을 작성하고 있어요."))) XCTAssertTrue(replyStore.isSendEnabled) try await capture(replyWindow, name: "reply-draft") + let middleReply = replyStore.replies[1] + scrollToBottom(replyScroll) + let middleOffset = replyScroll.contentOffset.y + replyStore.send(.view(.replyMenu(id: middleReply.id, action: .more))) + XCTAssertEqual(replyStore.menuTargetReplyId, middleReply.id) + try await Task.sleep(for: .milliseconds(350)) + XCTAssertEqual(replyScroll.contentOffset.y, middleOffset, accuracy: 1) + let middleMenuImage = try await capture(replyWindow, name: "reply-middle-row-menu") + try assertMenuLabels(["수정", "삭제"], visibleIn: replyScroll, window: replyWindow, image: middleMenuImage) + replyStore.send(.view(.replyMenu(id: middleReply.id, action: .more))) let lastReply = try XCTUnwrap(replyStore.replies.last) + scrollToTop(replyScroll) + XCTAssertGreaterThan(replyScroll.contentSize.height, replyScroll.bounds.height) + let topOffset = -replyScroll.adjustedContentInset.top + XCTAssertEqual(replyScroll.contentOffset.y, topOffset, accuracy: 1) replyStore.send(.view(.replyMenu(id: lastReply.id, action: .more))) XCTAssertEqual(replyStore.menuTargetReplyId, lastReply.id) - scrollToBottom(replyScroll) + try await Task.sleep(for: .milliseconds(350)) + XCTAssertGreaterThan(replyScroll.contentOffset.y, topOffset + 1) let replyMenuImage = try await capture(replyWindow, name: "reply-last-row-menu") try assertMenuLabels(["수정", "삭제"], visibleIn: replyScroll, window: replyWindow, image: replyMenuImage) replyStore.send(.view(.replyMenu(id: lastReply.id, action: .edit))) @@ -181,13 +199,16 @@ final class CommentFinalDesignTests: XCTestCase { try await waitFor("reply edit focus") { hasFocusedInput(in: replyWindow) } try await capture(replyWindow, name: "reply-edit") + replyWindow.endEditing(true) + try await waitFor("reply focus released before parent menu") { !hasFocusedInput(in: replyWindow) } + scrollToBottom(replyScroll) + let parentBottomOffset = replyScroll.contentOffset.y + XCTAssertGreaterThan(parentBottomOffset, topOffset + 1) replyStore.send(.view(.parentMenu(.more))) XCTAssertTrue(replyStore.parentMenuOpen) - scrollToTop(replyScroll) + try await Task.sleep(for: .milliseconds(350)) let parentMenuImage = try await capture(replyWindow, name: "reply-parent-menu") try assertMenuLabels(["수정", "삭제"], visibleIn: replyScroll, window: replyWindow, image: parentMenuImage) - replyWindow.endEditing(true) - try await waitFor("reply focus released before parent edit") { !hasFocusedInput(in: replyWindow) } replyStore.send(.view(.parentMenu(.edit))) try await waitFor("parent edit focus") { hasFocusedInput(in: replyWindow) } XCTAssertTrue(replyStore.editingParent) @@ -200,6 +221,59 @@ final class CommentFinalDesignTests: XCTestCase { XCTAssertFalse(replyStore.editingParent) } + @MainActor + func testParentMenuScrollsIntoViewFromBelowReplies() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to verify the off-screen parent menu") + } + + let session = ClassMockChatSession() + let battleID = await session.register(room: .mockJoinable) + let battleAPI: any BattleInterface = session + _ = try await battleAPI.submitPreVote(battleId: battleID, optionId: 1) + _ = try await battleAPI.submitPostVote(battleId: battleID, optionId: 1) + let createdPerspective = try await battleAPI.createPerspective( + battleId: battleID, + content: "화면 위쪽에 놓일 부모 의견입니다.", + optionId: 1 + ) + let perspective = try XCTUnwrap(createdPerspective) + let perspectiveAPI: any PerspectiveInterface = session + for index in 1 ... 12 { + _ = try await perspectiveAPI.createComment( + perspectiveId: perspective.perspectiveId, + content: "\(index)번째 답글입니다. 부모 의견 아래로 목록을 충분히 길게 만듭니다." + ) + } + + let store = Store(initialState: CommentReplyFeature.State( + perspectiveId: perspective.perspectiveId, + parentComment: CommentItem(item: perspective, order: 0) + )) { + CommentReplyFeature() + .dependency(\.perspectiveUseCase, session) + .dependency(\.commentUseCase, session) + .dependency(\.analyticsUseCase, .testValue) + } + let window = try makeWindow(rootView: CommentReplyView(store: store)) + defer { window.isHidden = true } + try await waitFor("long reply list loaded") { + !store.isLoadingDetail && !store.isLoadingReplies && store.replies.count == 12 + } + + let scroll = try XCTUnwrap(verticalScrollView(in: window)) + scrollToBottom(scroll) + let bottomOffset = scroll.contentOffset.y + XCTAssertGreaterThan(bottomOffset, 500, "The parent menu must begin outside the viewport") + + store.send(.view(.parentMenu(.more))) + XCTAssertTrue(store.parentMenuOpen) + try await Task.sleep(for: .milliseconds(350)) + XCTAssertLessThan(scroll.contentOffset.y, bottomOffset - 1) + let image = try await capture(window, name: "reply-parent-menu-from-bottom") + try assertMenuLabels(["수정", "삭제"], visibleIn: scroll, window: window, image: image) + } + @MainActor private func makeCommentStore( battleID: Int, @@ -334,22 +408,38 @@ final class CommentFinalDesignTests: XCTestCase { let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.recognitionLanguages = ["ko-KR", "en-US"] + request.usesLanguageCorrection = false // Read the trailing menu separately so text behind the overlay cannot merge // with its short labels during line recognition. let menuRegion = CGRect(x: 0.76, y: 0, width: 0.24, height: 1) request.regionOfInterest = menuRegion try VNImageRequestHandler(cgImage: XCTUnwrap(image.cgImage)).perform([request]) let viewport = scroll.convert(scroll.bounds, to: window).intersection(window.bounds) + var observedBounds: [CGRect] = [] for label in labels { - let observation = try XCTUnwrap((request.results ?? []).first { - $0.topCandidates(1).first?.string.contains(label) == true - }, "Last-row menu label \(label) must appear in rendered pixels") + let observation = (request.results ?? []).first(where: { + $0.topCandidates(5).contains { + $0.string.replacingOccurrences(of: " ", with: "").contains(label) + } + }) + XCTAssertNotNil(observation, "Menu label \(label) must be readable in the capture") + guard let observation else { continue } let bounds = observation.boundingBox - let center = CGPoint( - x: (menuRegion.minX + bounds.midX * menuRegion.width) * window.bounds.width, - y: (1 - (menuRegion.minY + bounds.midY * menuRegion.height)) * window.bounds.height + let labelFrame = CGRect( + x: (menuRegion.minX + bounds.minX * menuRegion.width) * window.bounds.width, + y: (1 - (menuRegion.minY + bounds.maxY * menuRegion.height)) * window.bounds.height, + width: bounds.width * menuRegion.width * window.bounds.width, + height: bounds.height * menuRegion.height * window.bounds.height + ) + XCTAssertTrue(viewport.contains(labelFrame), "Menu label \(label) is outside the visible comment list") + observedBounds.append(labelFrame) + } + if observedBounds.count == labels.count { + XCTAssertEqual( + Set(observedBounds.map { $0.midY.rounded() }).count, + labels.count, + "Menu labels must occupy separate visible buttons" ) - XCTAssertTrue(viewport.contains(center), "Menu label \(label) is outside the visible comment list") } } diff --git a/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift b/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift index db5efe5d..82c64f45 100644 --- a/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift @@ -15,6 +15,43 @@ import Testing @MainActor struct MainTabCoordinatorTests { + @Test + func 클래스_미리듣기는_상위_분석_의존성을_사용한다() async { + let tracked = LockIsolated(false) + var state = MainTabCoordinator.State(selectedTab: MainTabCoordinator.Tab.classroom.rawValue) + var preview = ClassBattlePreviewFeature.State(battle: .mocks[0]) + preview.scenario = BattleScenario( + battleId: preview.battle.id, + title: "미리 듣기", + philosophers: [], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: [:], + nodes: [] + ) + state.classState.routes.push(.battlePreview(preview)) + var activePreview = preview + activePreview.isActive = true + let store = TestStore(initialState: state) { + MainTabCoordinator() + } withDependencies: { + $0.analyticsUseCase.track = { event in + guard case .screenView(screen: .classBattlePreview, referrer: .classRecommend) = event else { return } + tracked.setValue(true) + } + } + + await store.send(.classroom(.router(.routeAction( + id: 1, + action: .battlePreview(.view(.onAppear)) + )))) { + $0.classState.routes[1].screen = .battlePreview(activePreview) + } + #expect(tracked.value) + await store.finish() + } + @Test func 탭_변경은_한번만_기록하고_재선택과_잘못된_탭은_기록하지_않는다() async { let events = LockIsolated<[AnalyticsButton]>([]) diff --git a/Projects/Domain/ClassDomain/Project.swift b/Projects/Domain/ClassDomain/Project.swift index e4798e03..d39905aa 100644 --- a/Projects/Domain/ClassDomain/Project.swift +++ b/Projects/Domain/ClassDomain/Project.swift @@ -16,7 +16,9 @@ let project = Project.makeModule( bundleId: .appBundleID(name: ".ClassDomain"), product: .framework, settings: .settings(), - dependencies: [], + dependencies: [ + .serviceAssembly, + ], hasTests: true, hasInterface: true, interfaceDependencies: [ diff --git a/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift b/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift index 689e8d66..e2f3a67d 100644 --- a/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift +++ b/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift @@ -28,7 +28,10 @@ extension ClassAIFeedbackUseCaseDependency: DependencyKey { extension ClassRepositoryDependency: DependencyKey { public static var liveValue: any ClassInterface { - ClassMockRepositoryStorage.shared + ClassLiveRepository( + mock: ClassMockRepositoryStorage.shared, + recommendations: ClassRepositoryImpl() + ) } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift new file mode 100644 index 00000000..503fad8f --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift @@ -0,0 +1,62 @@ +import ClassDomainInterface +import Foundation + +struct ClassBattleRecommendationDataDTO: Decodable, Sendable { + let items: [ClassBattleRecommendationItemDTO] +} + +struct ClassBattleRecommendationItemDTO: Decodable, Sendable { + let battleId: Int + let title: String + let summary: String? + let thumbnailUrl: String? + let category: String + let difficulty: String + let audioDuration: Int? + let options: [ClassBattleRecommendationOptionDTO] + + func toDomain() throws -> ClassBattleSummary { + guard let category = ClassCategory.allCases.first(where: { $0.title == self.category }) else { + throw ClassError.unknown("알 수 없는 배틀 카테고리: \(category)") + } + let level: ClassAudienceLevel + switch difficulty { + case "BEGINNER": level = .middle + case "INTERMEDIATE": level = .high + case "ADVANCED": level = .adult + default: throw ClassError.unknown("알 수 없는 배틀 수준: \(difficulty)") + } + guard let optionA = options.first(where: { $0.position == "A" }), + let optionB = options.first(where: { $0.position == "B" }) + else { + throw ClassError.unknown("배틀의 A/B 선택지가 누락됐습니다.") + } + return ClassBattleSummary( + id: battleId, + title: title, + summary: summary ?? "", + thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), + philosopherA: optionA.representative ?? "", + optionATitle: optionA.title, + optionADescription: optionA.stance, + philosopherAImageURL: optionA.imageUrl.flatMap(URL.init(string:)), + philosopherB: optionB.representative ?? "", + optionBTitle: optionB.title, + optionBDescription: optionB.stance, + philosopherBImageURL: optionB.imageUrl.flatMap(URL.init(string:)), + category: category, + level: level, + audioDuration: audioDuration ?? 0, + viewCount: 0 + ) + } +} + +struct ClassBattleRecommendationOptionDTO: Decodable, Sendable { + let optionId: Int + let position: String + let title: String + let stance: String? + let representative: String? + let imageUrl: String? +} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassLiveRepository.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassLiveRepository.swift new file mode 100644 index 00000000..8b68787a --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassLiveRepository.swift @@ -0,0 +1,48 @@ +import ClassDomainInterface +import Foundation + +/// 아직 서버에 연결되지 않은 클래스 기능은 공유 목 상태를 유지한다. +struct ClassLiveRepository: ClassInterface { + let mock: any ClassInterface + let recommendations: any ClassInterface + + func fetchMyClasses() async throws -> [ClassRoom] { + try await mock.fetchMyClasses() + } + + func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { + try await recommendations.fetchRecommendedBattles(filter: filter) + } + + func createClass(_ creation: ClassCreation) async throws -> ClassRoom { + try await mock.createClass(creation) + } + + func fetchClass(joinCode: String) async throws -> ClassRoom { + try await mock.fetchClass(joinCode: joinCode) + } + + func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { + try await mock.joinClass(joinCode: joinCode, nickname: nickname) + } + + func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom { + try await mock.updateDeadline(id: id, deadline: deadline) + } + + func deleteClass(id: Int) async throws { + try await mock.deleteClass(id: id) + } + + func leaveClass(id: Int) async throws { + try await mock.leaveClass(id: id) + } + + func fetchReport(roomID: Int) async throws -> ClassReport? { + try await mock.fetchReport(roomID: roomID) + } + + func fetchOwnerDashboard(roomID: Int) async throws -> ClassOwnerDashboardData? { + try await mock.fetchOwnerDashboard(roomID: roomID) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift index 0595f21b..96939073 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift @@ -6,10 +6,13 @@ // import ClassDomainInterface +import Dependencies import Foundation +import PickeNetwork public struct ClassRepositoryImpl: ClassInterface { private static let unavailableMessage = "클래스 서버 API가 연결되지 않았습니다." + @Dependency(\.networkClient) private var client public init() {} @@ -17,8 +20,22 @@ public struct ClassRepositoryImpl: ClassInterface { throw ClassError.network(Self.unavailableMessage) } - public func fetchRecommendedBattles(filter _: ClassTopicFilter) async throws -> [ClassBattleSummary] { - throw ClassError.network(Self.unavailableMessage) + public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { + guard (1 ... 3).contains(filter.categories.count), filter.keyword.count <= 50 else { + throw ClassError.unknown("추천 조건을 확인해 주세요.") + } + do { + let data = try await client.send( + ClassBattleRecommendationService.recommendations(filter: filter), + as: ClassBattleRecommendationDataDTO.self + ) + return try data.items.map { try $0.toDomain() } + } catch let error as PickeNetworkError { + if case let .response(response) = error { + throw ClassError.network(response.message ?? "배틀 추천을 불러오지 못했습니다.") + } + throw ClassError.from(error) + } } public func createClass(_: ClassCreation) async throws -> ClassRoom { diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassBattleRecommendationService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassBattleRecommendationService.swift new file mode 100644 index 00000000..2bd9d028 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Service/ClassBattleRecommendationService.swift @@ -0,0 +1,60 @@ +import Alamofire +import API +import ClassDomainInterface +import Foundation +import PickeNetwork + +enum ClassBattleRecommendationService { + case recommendations(filter: ClassTopicFilter) +} + +extension ClassBattleRecommendationService: PickeDataRequest { + var domain: any PickeDomainType { + ClassBattleRecommendationDomain() + } + + var path: String { + "class-battle-recommendations" + } + + var method: HTTPMethod { + .get + } + + var parameters: (any Encodable & Sendable)? { + switch self { + case let .recommendations(filter): + return ClassBattleRecommendationQuery( + difficulty: filter.level.apiValue, + categories: filter.categories.map(\.title).joined(separator: ","), + keyword: filter.keyword.isEmpty ? nil : filter.keyword + ) + } + } +} + +private struct ClassBattleRecommendationDomain: PickeDomainType { + var baseURLString: String { + PieckeDomain.battle.baseURLString + } + + var url: String { + "api/v1/" + } +} + +private struct ClassBattleRecommendationQuery: Encodable, Sendable { + let difficulty: String + let categories: String + let keyword: String? +} + +private extension ClassAudienceLevel { + var apiValue: String { + switch self { + case .middle: return "BEGINNER" + case .high: return "INTERMEDIATE" + case .adult: return "ADVANCED" + } + } +} diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift new file mode 100644 index 00000000..ecc23310 --- /dev/null +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift @@ -0,0 +1,145 @@ +@testable import ClassDomain +import ClassDomainInterface +import Dependencies +import Foundation +import PickeNetwork +import Testing + +struct ClassRecommendationTests { + @Test + func 추천_요청은_수준과_카테고리와_검색어를_쿼리로_보낸다() throws { + let request = ClassBattleRecommendationService.recommendations( + filter: .init(keyword: "인간 자유", level: .high, categories: [.philosophy, .society]) + ) + let url = try request.url() + let parameters = try #require(request.parameters) + let json = try JSONEncoder().encode(parameters) + let query = try #require(JSONSerialization.jsonObject(with: json) as? [String: String]) + + #expect(url.path == "/api/v1/class-battle-recommendations") + #expect(request.method == .get) + #expect(query == [ + "difficulty": "INTERMEDIATE", + "categories": "철학,사회", + "keyword": "인간 자유", + ]) + } + + @Test + func 추천_응답은_위치가_뒤바뀐_AB_선택지를_올바르게_매핑한다() async throws { + let json = """ + {"statusCode":200,"data":{"items":[{ + "battleId":12,"title":"AI가 쓴 글도 창작일까?","summary":"요약", + "thumbnailUrl":"https://example.com/thumb.png","category":"철학", + "difficulty":"BEGINNER","audioDuration":180, + "options":[ + {"optionId":32,"position":"B","title":"창작이 아니다","stance":"반대 근거","representative":"칸트","imageUrl":"https://example.com/b.png"}, + {"optionId":31,"position":"A","title":"창작이다","stance":"찬성 근거","representative":"니체","imageUrl":"https://example.com/a.png"} + ] + }]}} + """ + let repository = withDependencies { + $0.networkClient = ClassRecommendationStubClient(data: Data(json.utf8)) + } operation: { + ClassRepositoryImpl() + } + + let battles = try await repository.fetchRecommendedBattles(filter: .init(category: .philosophy)) + let battle = try #require(battles.first) + #expect(battles.count == 1) + #expect(battle.id == 12) + #expect(battle.thumbnailURL?.absoluteString == "https://example.com/thumb.png") + #expect(battle.category == .philosophy) + #expect(battle.level == .middle) + #expect(battle.audioDuration == 180) + #expect(battle.optionATitle == "창작이다") + #expect(battle.optionADescription == "찬성 근거") + #expect(battle.philosopherA == "니체") + #expect(battle.philosopherAImageURL?.absoluteString == "https://example.com/a.png") + #expect(battle.optionBTitle == "창작이 아니다") + #expect(battle.philosopherB == "칸트") + } + + @Test + func 빈_추천_응답은_빈_목록이고_다른_클래스_기능은_목_저장소를_쓴다() async throws { + let json = #"{"statusCode":200,"data":{"items":[]}}"# + let live = ClassLiveRepository( + mock: MockClassRepository(rooms: [ClassRoom.mocks[0]], availableRooms: []), + recommendations: withDependencies { + $0.networkClient = ClassRecommendationStubClient(data: Data(json.utf8)) + } operation: { + ClassRepositoryImpl() + } + ) + + #expect(try await live.fetchRecommendedBattles(filter: .init(category: .philosophy)).isEmpty) + #expect(try await live.fetchMyClasses().map(\.id) == [ClassRoom.mocks[0].id]) + } + + @Test + func 서버_에러_메시지는_ClassError로_전달된다() async throws { + let json = #"{"statusCode":400,"error":{"code":"CLASS_400_RECOMMEND_CONDITION","message":"추천 조건 오류"}}"# + let repository = withDependencies { + $0.networkClient = ClassRecommendationStubClient(data: Data(json.utf8)) + } operation: { + ClassRepositoryImpl() + } + do { + _ = try await repository.fetchRecommendedBattles(filter: .init(category: .philosophy)) + Issue.record("서버 오류가 성공으로 처리됨") + } catch { + #expect(error as? ClassError == .network("추천 조건 오류")) + } + } +} + +private struct ClassRecommendationStubClient: PickeNetworkClient { + private struct Envelope: Decodable { + struct Failure: Decodable { + let code: String? + let message: String? + } + + let statusCode: Int? + let data: T? + let error: Failure? + } + + let data: Data + + func send( + _: some PickeDataRequest, + as _: T.Type + ) async throws(PickeNetworkError) -> T { + do { + let envelope = try JSONDecoder().decode(Envelope.self, from: data) + if let failure = envelope.error { + throw PickeNetworkError.response( + ResponseError(httpStatus: envelope.statusCode ?? 400, code: failure.code, message: failure.message) + ) + } + guard let payload = envelope.data else { throw PickeNetworkError.decoding(.dataMissing) } + return payload + } catch let error as PickeNetworkError { + throw error + } catch { + throw .decoding(.failed(error)) + } + } + + func send(_ request: R) async throws(PickeNetworkError) -> R.Response { + try await send(request, as: R.Response.self) + } + + func sendResponse(_: some PickeDataRequest) async throws(PickeNetworkError) -> PickeHTTPResponse { + PickeHTTPResponse(statusCode: 200, data: data) + } + + func upload(_: R) async throws(PickeNetworkError) -> R.Response { + throw .decoding(.dataMissing) + } + + func upload(_: some PickeFileUploadRequest) async throws(PickeNetworkError) { + throw .decoding(.dataMissing) + } +} diff --git a/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift index ad2eb4b2..f1ce0d40 100644 --- a/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -88,6 +88,18 @@ private extension CommentReplyView { .onChange(of: store.replies.count) { _, _ in scrollToTargetReply(using: proxy) } + .onChange(of: store.menuTargetReplyId) { _, id in + guard let id else { return } + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo("reply-menu-\(id)", anchor: nil) + } + } + .onChange(of: store.parentMenuOpen) { _, isOpen in + guard isOpen else { return } + withAnimation(.easeInOut(duration: 0.18)) { + proxy.scrollTo("parent-menu", anchor: nil) + } + } } } @@ -136,7 +148,7 @@ private extension CommentReplyView { func navigationBar() -> some View { PickeNavigationBar( onBack: { send(.backButtonTapped) }, - centerTitle: "댓글" + leadingTitle: "댓글" ) .foregroundStyle(.gray500) // Figma: 앱바는 beige200 위에 투명 배치, 하단 구분선 없음 @@ -150,30 +162,32 @@ private extension CommentReplyView { @ViewBuilder func parentCommentSection() -> some View { // Figma: 원본 댓글은 beige50 풀폭 행, 상·하단 구분선(beige600) - commentCard( - author: store.parentComment.isMine ? "나" : store.parentComment.author, - imageURL: store.parentComment.authorImageURL, - timeAgo: store.parentComment.timeAgo, - option: store.parentComment.option, - optionLabel: store.parentComment.optionLabel ?? "", - content: store.parentComment.content, - replyCount: store.parentComment.replyCount, - likeCount: store.parentComment.likeCount, - isLiked: store.parentComment.isLiked, - showsReplyCount: true, - moreAction: { send(.parentMenu(.more)) }, - likeAction: { send(.parentLikeTapped) } - ) - .topDivider(.beige600) - .overlay(alignment: .bottomTrailing) { + VStack(spacing: 0) { + commentCard( + author: store.parentComment.isMine ? "나" : store.parentComment.author, + imageURL: store.parentComment.authorImageURL, + timeAgo: store.parentComment.timeAgo, + option: store.parentComment.option, + content: store.parentComment.content, + replyCount: store.parentComment.replyCount, + likeCount: store.parentComment.likeCount, + isLiked: store.parentComment.isLiked, + showsReplyCount: true, + moreAction: { send(.parentMenu(.more)) }, + likeAction: { send(.parentLikeTapped) } + ) + .topDivider(.beige600) + if store.parentMenuOpen { parentMenuView() .padding(.trailing, 16) - .padding(.bottom, 28) - .zIndex(1) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .trailing) + .background(.beige50) + .bottomDivider(.beige600) + .id("parent-menu") } } - .zIndex(store.parentMenuOpen ? 1 : 0) } @ViewBuilder @@ -193,29 +207,32 @@ private extension CommentReplyView { func replySection() -> some View { VStack(alignment: .leading, spacing: 0) { ForEach(store.replies) { reply in - commentCard( - author: reply.isMine ? "나" : reply.author, - imageURL: reply.authorImageURL, - timeAgo: reply.timeAgo, - option: reply.option, - optionLabel: store.parentComment.optionLabel ?? "", - content: reply.content, - replyCount: nil, - likeCount: reply.likeCount, - isLiked: reply.isLiked, - showsReplyCount: false, - moreAction: { send(.replyMenu(id: reply.id, action: .more)) }, - likeAction: { send(.replyLikeTapped(reply.id)) } - ) - .overlay(alignment: .bottomTrailing) { + VStack(spacing: 0) { + commentCard( + author: reply.isMine ? "나" : reply.author, + imageURL: reply.authorImageURL, + timeAgo: reply.timeAgo, + option: reply.option, + content: reply.content, + replyCount: nil, + likeCount: reply.likeCount, + isLiked: reply.isLiked, + showsReplyCount: false, + moreAction: { send(.replyMenu(id: reply.id, action: .more)) }, + likeAction: { send(.replyLikeTapped(reply.id)) } + ) + if store.menuTargetReplyId == reply.id { replyMenu(for: reply) .padding(.trailing, 16) - .padding(.bottom, 28) - .zIndex(1) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .trailing) + .background(.beige50) + .bottomDivider(.beige600) + .id("reply-menu-\(reply.id)") } } - .zIndex(store.menuTargetReplyId == reply.id ? 1 : 0) + .id(reply.id) } } } @@ -226,7 +243,6 @@ private extension CommentReplyView { imageURL: String?, timeAgo: String, option: CommentOption, - optionLabel: String, content: String, replyCount: Int?, likeCount: Int, @@ -242,7 +258,7 @@ private extension CommentReplyView { imageURL: imageURL, timeAgo: timeAgo, option: option, - optionLabel: optionLabel + moreAction: moreAction ) Text(content) @@ -253,6 +269,12 @@ private extension CommentReplyView { .padding(.horizontal, 2) HStack(spacing: 11) { + Spacer() + + if showsReplyCount, let replyCount { + actionLabel(systemName: "message", text: "\(replyCount)") + } + Button(action: likeAction) { HStack(spacing: 2) { Image(asset: .heartPlus) @@ -265,24 +287,6 @@ private extension CommentReplyView { .foregroundStyle(isLiked ? .primary500 : .gray300) } .buttonStyle(.plain) - - if showsReplyCount, let replyCount { - actionLabel(systemName: "message", text: "\(replyCount)") - } - - Spacer() - - if let moreAction { - Button(action: moreAction) { - Image(systemName: "ellipsis") - .pretendardFont(family: .Regular, size: 18) - .foregroundStyle(.gray300) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("더보기") - } } .foregroundStyle(.gray300) } @@ -299,7 +303,7 @@ private extension CommentReplyView { imageURL: String?, timeAgo: String, option: CommentOption, - optionLabel: String + moreAction: (() -> Void)? ) -> some View { HStack(spacing: 8) { PickeAvatarView( @@ -308,11 +312,15 @@ private extension CommentReplyView { size: 36 ) - VStack(alignment: .leading, spacing: 0) { - Text(author) - .pretendardFont(.semiBold12) - .foregroundStyle(.gray500) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(author) + .pretendardFont(.semiBold12) + .foregroundStyle(.gray500) + .lineLimit(1) + + optionBadge(label: option.label, option: option) + } // 시간 — caption/sm/semibold Pretendard SemiBold 10, gray300 Text(timeAgo) @@ -322,7 +330,17 @@ private extension CommentReplyView { Spacer() - optionBadge(label: optionLabel, option: option) + if let moreAction { + Button(action: moreAction) { + Image(systemName: "ellipsis") + .pretendardFont(family: .Regular, size: 18) + .foregroundStyle(.gray300) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("더보기") + } } } diff --git a/Projects/Feature/Class/Project.swift b/Projects/Feature/Class/Project.swift index 8e32cccd..7e9b3f44 100644 --- a/Projects/Feature/Class/Project.swift +++ b/Projects/Feature/Class/Project.swift @@ -20,8 +20,11 @@ let project = Project.makeModule( .core(.coreUtility), .ui(.designKit), .ui(.sharedUI), + .service(.audioPlayer, .interface), + .service(.analytics, .interface), .domain(.battle, .interface), .domain(.classroom, .interface), + .domain(.home, .interface), .SPM.composableArchitecture, ], testDependencies: [.SPM.issueReportingTestSupport], diff --git a/Projects/Feature/Class/Sources/ClassPreview/Dependency/ClassBattlePreviewDependencies.swift b/Projects/Feature/Class/Sources/ClassPreview/Dependency/ClassBattlePreviewDependencies.swift new file mode 100644 index 00000000..afdaf220 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassPreview/Dependency/ClassBattlePreviewDependencies.swift @@ -0,0 +1,38 @@ +import AudioPlayerServiceInterface +import BattleDomainInterface +import ComposableArchitecture +import PickeAnalyticsInterface + +/// 클래스의 목 채팅 의존성과 별도로 미리 듣기에 사용할 실제 서비스들을 전달한다. +public struct ClassBattlePreviewDependencies: Sendable { + public var battleUseCase: any BattleInterface + public var audioPlayer: any AudioPlayerInterface + public var analyticsUseCase: AnalyticsUseCase + + public init( + battleUseCase: any BattleInterface, + audioPlayer: any AudioPlayerInterface, + analyticsUseCase: AnalyticsUseCase + ) { + self.battleUseCase = battleUseCase + self.audioPlayer = audioPlayer + self.analyticsUseCase = analyticsUseCase + } +} + +extension ClassBattlePreviewDependencies: TestDependencyKey { + public static var testValue: Self { + Self( + battleUseCase: BattleUseCaseDependency.testValue, + audioPlayer: AudioPlayerDependency.testValue, + analyticsUseCase: AnalyticsUseCase.testValue + ) + } +} + +public extension DependencyValues { + var classBattlePreviewDependencies: ClassBattlePreviewDependencies { + get { self[ClassBattlePreviewDependencies.self] } + set { self[ClassBattlePreviewDependencies.self] = newValue } + } +} diff --git a/Projects/Feature/Class/Sources/ClassPreview/Reducer/ClassBattlePreviewFeature.swift b/Projects/Feature/Class/Sources/ClassPreview/Reducer/ClassBattlePreviewFeature.swift new file mode 100644 index 00000000..f751c3bc --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassPreview/Reducer/ClassBattlePreviewFeature.swift @@ -0,0 +1,420 @@ +// +// ClassBattlePreviewFeature.swift +// Class +// +// Created by Picke on 10/21/26. +// + +import AudioPlayerServiceInterface +import BattleDomainInterface +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import HomeDomainInterface +import PickeAnalyticsInterface + +@Reducer +public struct ClassBattlePreviewFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public enum ViewState: Equatable { + case loading + case loaded + case error + } + + public let instanceID: UUID + public let battle: ClassBattleSummary + public var viewState: ViewState = .loading + public var scenario: BattleScenario? + public var isActive = false + public var isAudioReady = false + public var isPlaying = false + public var hasFinished = false + public var playbackRate: Float = 1 + public var currentTime: TimeInterval = 0 + public var audioDuration: TimeInterval = 60 + public var pendingSeekTime: TimeInterval? + public var audioErrorMessage: String? + + public init(battle: ClassBattleSummary) { + self.battle = battle + instanceID = UUID() + } + + public var title: String { + scenario?.title ?? battle.title + } + + public var previewDuration: TimeInterval { + min(60, max(0, audioDuration)) + } + + public var progress: Double { + guard previewDuration > 0 else { return 0 } + return min(1, max(0, currentTime / previewDuration)) + } + + public var currentTimeText: String { + Self.timeText(currentTime) + } + + public var durationText: String { + Self.timeText(previewDuration) + } + + private static func timeText(_ time: TimeInterval) -> String { + let seconds = Int(max(0, time)) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + public var playbackRateTitle: String { + switch playbackRate { + case 1: return "1.0" + case 1.25: return "1.25" + case 1.5: return "1.5" + case 2: return "2.0" + default: return "1.0" + } + } + + public var previewScripts: [ScenarioScript] { + guard let scenario, + let node = scenario.nodes.first(where: { $0.nodeId == scenario.startNodeId }) + else { return [] } + return node.scripts.filter { $0.startTimeMs < 60000 } + .sorted { $0.startTimeMs < $1.startTimeMs } + } + + public func speaker(for script: ScenarioScript) -> ChatSpeaker { + let label: String? + let side: ChatSpeakerSide + switch script.speakerType { + case .a: + label = "A" + side = .left + case .b: + label = "B" + side = .right + case .narrator: + return ChatSpeaker(name: script.speakerName, side: .center) + case .philosopher, .unknown: + label = scenario?.philosophers.first(where: { $0.name == script.speakerName })?.label + side = label == "B" ? .right : .left + } + let philosopher = scenario?.philosophers.first { $0.label == label } + return ChatSpeaker( + label: label, + name: philosopher?.name ?? script.speakerName, + imageURL: philosopher?.imageUrl, + side: side + ) + } + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case onDisappear + case backTapped + case retryTapped + case togglePlayTapped + case playbackRateSelected(Float) + case seekBackwardTapped + case seekForwardTapped + case replayTapped + case scrubbed(TimeInterval) + } + + public enum AsyncAction: Equatable { + case fetchScenario(Int) + case loadAudio(URL) + case observeTime(UUID) + } + + public enum InnerAction: Equatable { + case scenarioResponse(Result) + case audioLoaded(Bool, TimeInterval) + case playerTimeUpdated(TimeInterval) + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case scenario(UUID) + case audioLoad(UUID) + case audioObserver(UUID) + } + + @Dependency(\.classBattlePreviewDependencies) private var previewDependencies + + private var battleUseCase: any BattleInterface { + previewDependencies.battleUseCase + } + + private var audioPlayer: any AudioPlayerInterface { + previewDependencies.audioPlayer + } + + private var analyticsUseCase: AnalyticsUseCase { + previewDependencies.analyticsUseCase + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(view): + return handleView(state: &state, action: view) + case let .async(async): + return handleAsync(state: &state, action: async) + case let .inner(inner): + return handleInner(state: &state, action: inner) + case .delegate: + return .none + } + } + } +} + +private extension ClassBattlePreviewFeature { + func handleView( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard !state.isActive else { return .none } + state.isActive = true + analyticsUseCase.track(.screenView(screen: .classBattlePreview, referrer: .classRecommend)) + if let scenario = state.scenario { + if state.isAudioReady { + return .send(.async(.observeTime(state.instanceID))) + } + guard state.audioErrorMessage == nil, + let url = audioURL(from: scenario) + else { return .none } + return .send(.async(.loadAudio(url))) + } + state.viewState = .loading + return .send(.async(.fetchScenario(state.battle.id))) + + case .onDisappear: + state.isActive = false + state.isPlaying = false + return stopEffects(instanceID: state.instanceID) + + case .backTapped: + state.isActive = false + state.isPlaying = false + return .merge(stopEffects(instanceID: state.instanceID), .send(.delegate(.dismiss))) + + case .retryTapped: + guard state.isActive else { return .none } + state.viewState = .loading + state.audioErrorMessage = nil + return .send(.async(.fetchScenario(state.battle.id))) + + case .togglePlayTapped: + guard state.isAudioReady else { return .none } + if state.hasFinished { + analyticsUseCase.track(.playbackAction(action: .replay, contentID: "\(state.battle.id)")) + state.currentTime = 0 + state.hasFinished = false + state.pendingSeekTime = 0 + state.isPlaying = true + return .run { [player = audioPlayer] _ in + await player.seek(to: 0) + await player.play() + } + } + state.isPlaying.toggle() + let shouldPlay = state.isPlaying + analyticsUseCase.track(.playbackAction( + action: shouldPlay ? .play : .pause, + contentID: "\(state.battle.id)" + )) + return .run { [player = audioPlayer] _ in + if shouldPlay { + await player.play() + } else { + await player.pause() + } + } + + case let .playbackRateSelected(rate): + guard [Float(1), 1.25, 1.5, 2].contains(rate) else { return .none } + state.playbackRate = rate + return .run { [player = audioPlayer] _ in await player.setPlaybackRate(rate) } + + case .seekBackwardTapped: + guard state.isAudioReady else { return .none } + analyticsUseCase.track(.playbackAction(action: .skip15s, contentID: "\(state.battle.id)")) + return seek(state: &state, to: state.currentTime - 15) + + case .seekForwardTapped: + guard state.isAudioReady else { return .none } + analyticsUseCase.track(.playbackAction(action: .skip15s, contentID: "\(state.battle.id)")) + return seek(state: &state, to: state.currentTime + 15) + + case .replayTapped: + guard state.isAudioReady else { return .none } + analyticsUseCase.track(.playbackAction(action: .replay, contentID: "\(state.battle.id)")) + state.currentTime = 0 + state.pendingSeekTime = 0 + state.hasFinished = false + state.isPlaying = true + return .run { [player = audioPlayer] _ in + await player.seek(to: 0) + await player.play() + } + + case let .scrubbed(time): + guard state.isAudioReady else { return .none } + analyticsUseCase.track(.playbackAction(action: .seek, contentID: "\(state.battle.id)")) + return seek(state: &state, to: time) + } + } + + func handleAsync( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetchScenario(battleID): + return .run { [useCase = battleUseCase] send in + let result = await Result { try await useCase.fetchScenario(battleId: battleID) } + .mapError(BattleError.from) + guard !Task.isCancelled else { return } + await send(.inner(.scenarioResponse(result))) + } + .cancellable(id: CancelID.scenario(state.instanceID), cancelInFlight: true) + + case let .loadAudio(url): + return .run { [player = audioPlayer, rate = state.playbackRate] send in + let loaded = await player.load(url: url) + guard !Task.isCancelled else { return } + let duration = loaded ? await player.duration() : 0 + guard !Task.isCancelled else { return } + await player.setPlaybackRate(rate) + await send(.inner(.audioLoaded(loaded, duration))) + } + .cancellable(id: CancelID.audioLoad(state.instanceID), cancelInFlight: true) + + case let .observeTime(instanceID): + return .run { [player = audioPlayer] send in + for await time in player.currentTimes() { + guard !Task.isCancelled else { return } + await send(.inner(.playerTimeUpdated(time))) + } + } + .cancellable(id: CancelID.audioObserver(instanceID), cancelInFlight: true) + } + } + + func handleInner( + state: inout State, + action: InnerAction + ) -> Effect { + guard state.isActive else { return .none } + switch action { + case let .scenarioResponse(result): + switch result { + case let .success(scenario): + state.scenario = scenario + state.viewState = .loaded + guard let url = audioURL(from: scenario) else { + state.audioErrorMessage = "미리 들을 수 있는 음원이 없어요" + analyticsUseCase.track(.classPreviewFailed( + battleID: state.battle.id, + reason: "missing_audio" + )) + return .none + } + return .send(.async(.loadAudio(url))) + case .failure: + state.viewState = .error + analyticsUseCase.track(.classPreviewFailed( + battleID: state.battle.id, + reason: "scenario_fetch" + )) + return .none + } + + case let .audioLoaded(loaded, duration): + guard loaded else { + state.audioErrorMessage = "오디오를 불러오지 못했어요" + analyticsUseCase.track(.classPreviewFailed( + battleID: state.battle.id, + reason: "audio_load" + )) + return .none + } + state.isAudioReady = true + state.audioDuration = duration > 0 ? duration : 60 + state.currentTime = 0 + state.isPlaying = true + return .merge( + .send(.async(.observeTime(state.instanceID))), + .run { [player = audioPlayer] _ in await player.play() } + ) + + case let .playerTimeUpdated(time): + guard state.isAudioReady else { return .none } + if let target = state.pendingSeekTime { + guard abs(time - target) < 0.5 * max(1, Double(state.playbackRate)) else { return .none } + state.pendingSeekTime = nil + } + let limit = state.previewDuration + state.currentTime = min(limit, max(0, time)) + let finishThreshold = state.audioDuration <= 60 ? max(0, limit - 0.3) : limit + guard time >= finishThreshold else { return .none } + state.isPlaying = false + state.hasFinished = true + return .run { [player = audioPlayer] _ in await player.pause() } + } + } + + func seek( + state: inout State, + to proposedTime: TimeInterval + ) -> Effect { + guard state.isAudioReady else { return .none } + let target = min(state.previewDuration, max(0, proposedTime)) + state.currentTime = target + state.pendingSeekTime = target + if target < state.previewDuration { + state.hasFinished = false + } + return .run { [player = audioPlayer] _ in await player.seek(to: target) } + } + + func audioURL(from scenario: BattleScenario) -> URL? { + guard let urlString = scenario.audios[scenario.recommendedPathKey.rawValue] + ?? scenario.audios.values.first, + let url = URL(string: urlString), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") + else { return nil } + return url + } + + func stopEffects(instanceID: UUID) -> Effect { + .merge( + .cancel(id: CancelID.scenario(instanceID)), + .cancel(id: CancelID.audioLoad(instanceID)), + .cancel(id: CancelID.audioObserver(instanceID)), + .run { [player = audioPlayer] _ in await player.pause() } + ) + } +} diff --git a/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift b/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift new file mode 100644 index 00000000..c2637e1c --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassPreview/View/ClassBattlePreviewView.swift @@ -0,0 +1,238 @@ +// +// ClassBattlePreviewView.swift +// Class +// +// Created by Picke on 10/21/26. +// + +import ComposableArchitecture +import HomeDomainInterface +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassBattlePreviewFeature.self) +public struct ClassBattlePreviewView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + navigationBar + previewBadge + conversation() + playerBar() + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .onAppear { send(.onAppear) } + .onDisappear { send(.onDisappear) } + } +} + +private extension ClassBattlePreviewView { + var navigationBar: some View { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: store.title + ) + .foregroundStyle(.neutral900) + } + + var previewBadge: some View { + Label("1분 미리 듣기 중", systemImage: "play.fill") + .pretendardFont(.semiBold12) + .foregroundStyle(.primary500) + .padding(.horizontal, 10) + .frame(height: 25) + .background(.beige500, in: Capsule()) + .frame(maxWidth: .infinity) + .padding(.top, 4) + .padding(.bottom, 20) + } + + @ViewBuilder + func conversation() -> some View { + switch store.viewState { + case .loading: + ProgressView() + .tint(.primary500) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .error: + PickeRetryErrorView(message: "대화를 불러오지 못했어요") { + send(.retryTapped) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .loaded: + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 22) { + ForEach(store.previewScripts) { script in + scriptRow(script) + .id(script.id) + } + + if let error = store.audioErrorMessage { + Text(error) + .pretendardFont(.regular13) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .padding(.top, 20) + } + } + .padding(.horizontal, 16) + .padding(.top, 2) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + .onChange(of: store.currentTime) { _, _ in + let scripts = store.previewScripts.filter { + TimeInterval($0.startTimeMs) / 1000 <= store.currentTime + } + if let last = scripts.last { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + } + + @ViewBuilder + func scriptRow(_ script: ScenarioScript) -> some View { + let speaker = store.withState { $0.speaker(for: script) } + switch speaker.side { + case .left: + HStack(alignment: .top, spacing: 8) { + avatar(speaker) + scriptContent(script, speaker: speaker) + Spacer(minLength: 0) + } + case .right: + HStack(alignment: .top, spacing: 8) { + Spacer(minLength: 0) + scriptContent(script, speaker: speaker) + avatar(speaker) + } + case .center: + scriptContent(script, speaker: speaker) + .frame(maxWidth: .infinity) + } + } + + func scriptContent( + _ script: ScenarioScript, + speaker: ChatSpeaker + ) -> some View { + VStack(alignment: speaker.side == .right ? .trailing : .leading, spacing: 6) { + Text(speaker.name) + .pretendardFont(.headingSmall) + .foregroundStyle(.neutral900) + + Text(script.text) + .pretendardFont(.regular13) + .foregroundStyle(.gray400) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .pickeCard( + speaker.side == .right ? .beige400 : .cardBaseBackgroundDefault, + insideBorder: speaker.side == .right + ) + } + .frame(maxWidth: .infinity, alignment: speaker.side == .right ? .trailing : .leading) + } + + func avatar(_ speaker: ChatSpeaker) -> some View { + Group { + switch speaker.name { + case "플라톤": + Image(asset: .avatarPlato) + .resizable() + .scaledToFit() + case "사르트르": + Image(asset: .avatarSartre) + .resizable() + .scaledToFit() + default: + PickeRemoteImage( + url: speaker.imageURL ?? "", + shape: .round(cornerRadius: 18) + ) + .content(.fit) + } + } + .frame(width: 36, height: 36) + .background(.beige600, in: Circle()) + } + + @ViewBuilder + func playerBar() -> some View { + VStack(spacing: 12) { + progressBar() + AudioPlayerControlView( + isPlaying: .constant(store.isPlaying), + onBackward: { send(.seekBackwardTapped) }, + onTogglePlay: { send(.togglePlayTapped) }, + onForward: { send(.seekForwardTapped) }, + onReplay: { send(.replayTapped) }, + isReplayEnabled: store.isAudioReady, + tint: .neutral900, + playbackRateTitle: store.playbackRateTitle, + onPlaybackRateSelected: { send(.playbackRateSelected($0)) } + ) + .disabled(!store.isAudioReady) + } + .padding(.horizontal, 24) + .padding(.top, 14) + .padding(.bottom, 8) + .background(.white) + } + + @ViewBuilder + func progressBar() -> some View { + VStack(spacing: 2) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(.neutral300) + .frame(height: 3) + Capsule() + .fill(.primary500) + .frame(width: geometry.size.width * store.progress, height: 3) + Circle() + .fill(.primary500) + .frame(width: 10, height: 10) + .offset(x: max(0, geometry.size.width * store.progress - 5)) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + guard store.isAudioReady, geometry.size.width > 0 else { return } + let ratio = min(1, max(0, value.location.x / geometry.size.width)) + send(.scrubbed(store.previewDuration * ratio)) + } + ) + } + .frame(height: 12) + + HStack { + Text(store.currentTimeText) + Spacer() + Text(store.durationText) + } + .pretendardFont(.medium11) + .foregroundStyle(.neutral300) + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift index a31354ee..d3b7d90e 100644 --- a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift +++ b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift @@ -117,6 +117,7 @@ public struct ClassRecommendFeature { case dismiss case select(ClassBattleSummary) case selectAIQuestion(ClassAIQuestion, ClassTopicFilter) + case previewRequested(ClassBattleSummary) } nonisolated enum CancelID: Hashable { @@ -186,8 +187,11 @@ extension ClassRecommendFeature { state.viewState = state.questions.isEmpty ? .empty : .loaded return .none - case .previewTapped: - return .none + case let .previewTapped(id): + guard state.mode == .existingContent, + let battle = state.battles.first(where: { $0.id == id }) + else { return .none } + return .send(.delegate(.previewRequested(battle))) case .selectTapped: if state.mode == .aiQuestions { diff --git a/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift b/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift index aa72cae4..56968d10 100644 --- a/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift +++ b/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift @@ -170,9 +170,9 @@ private extension ClassRecommendView { ClassBattleListCard( battle: battle, isSelected: store.selectedBattleId == battle.id, - onPreview: { send(.previewTapped(battle.id)) } + onPreview: { send(.previewTapped(battle.id)) }, + onSelect: { send(.battleTapped(battle.id)) } ) - .onTapGesture { send(.battleTapped(battle.id)) } } } } diff --git a/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift b/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift index e5105485..84b34ab7 100644 --- a/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift +++ b/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift @@ -15,14 +15,19 @@ struct ClassBattleListCard: View { let battle: ClassBattleSummary let isSelected: Bool let onPreview: () -> Void + let onSelect: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 16) { metaRow() titleSection() + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) } versusRow() + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) @@ -35,23 +40,28 @@ private extension ClassBattleListCard { @ViewBuilder func metaRow() -> some View { HStack(spacing: 10) { - Text("#\(battle.categoryDisplayTitle)") - .pickeBadge(.filled, size: .tag) + HStack(spacing: 2) { + Text("#\(battle.categoryDisplayTitle)") + .pickeBadge(.filled, size: .tag) - Spacer(minLength: 0) + Spacer(minLength: 0) - HStack(spacing: 2) { Image(systemName: "clock") .pretendardFont(.labelXSmall) Text("\(battle.durationMinutes)분") .pretendardFont(.labelSmall) } - .foregroundStyle(.gray300) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) previewButton - Image(systemName: "checkmark") - .pickeCheckbox(isChecked: isSelected) + Button(action: onSelect) { + Image(systemName: "checkmark") + .pickeCheckbox(isChecked: isSelected) + } + .buttonStyle(.plain) + .accessibilityLabel(isSelected ? "배틀 선택 해제" : "배틀 선택") } } @@ -66,8 +76,7 @@ private extension ClassBattleListCard { .foregroundStyle(.primary500) } .buttonStyle(.plain) - .disabled(true) - .accessibilityHint("미리듣기는 준비 중입니다") + .accessibilityLabel("\(battle.title) 미리듣기") } @ViewBuilder diff --git a/Projects/Feature/Class/Tests/Sources/ClassBattlePreviewTests.swift b/Projects/Feature/Class/Tests/Sources/ClassBattlePreviewTests.swift new file mode 100644 index 00000000..6b9acffa --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassBattlePreviewTests.swift @@ -0,0 +1,246 @@ +// +// ClassBattlePreviewTests.swift +// ClassTests +// +// Created by Picke on 10/21/26. +// + +import AudioPlayerServiceInterface +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import HomeDomainInterface +import Testing + +@MainActor +struct ClassBattlePreviewTests { + @Test + func 미리듣기는_60초에서_멈추고_오디오를_일시정지한다() async { + let player = RecordingPreviewAudioPlayer() + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.isActive = true + state.isAudioReady = true + state.isPlaying = true + state.audioDuration = 300 + state.currentTime = 59 + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } withDependencies: { + $0.classBattlePreviewDependencies.audioPlayer = player + } + + await store.send(.inner(.playerTimeUpdated(60.2))) { + $0.currentTime = 60 + $0.isPlaying = false + $0.hasFinished = true + } + await store.finish() + #expect(await player.pauses == 1) + } + + @Test + func 탐색은_60초_구간에_고정하고_이전_틱을_무시한다() async { + let player = RecordingPreviewAudioPlayer() + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.isActive = true + state.isAudioReady = true + state.currentTime = 52 + state.audioDuration = 240 + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } withDependencies: { + $0.classBattlePreviewDependencies.audioPlayer = player + } + + await store.send(.view(.seekForwardTapped)) { + $0.currentTime = 60 + $0.pendingSeekTime = 60 + } + await store.send(.inner(.playerTimeUpdated(52))) + await store.finish() + #expect(await player.seeks == [60]) + } + + @Test + func 화면을_떠나면_재생을_멈추고_후속_틱을_무시한다() async { + let player = RecordingPreviewAudioPlayer() + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.isActive = true + state.isAudioReady = true + state.isPlaying = true + state.currentTime = 13 + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } withDependencies: { + $0.classBattlePreviewDependencies.audioPlayer = player + } + + await store.send(.view(.onDisappear)) { + $0.isActive = false + $0.isPlaying = false + } + await store.send(.inner(.playerTimeUpdated(18))) + await store.finish() + #expect(await player.pauses == 1) + } + + @Test + func 화면에_다시_들어오면_시간_관찰을_재시작한다() async { + let player = RecordingPreviewAudioPlayer() + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.scenario = BattleScenario( + battleId: state.battle.id, + title: "미리 듣기", + philosophers: [], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: [:], + nodes: [] + ) + state.isAudioReady = true + let instanceID = state.instanceID + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } withDependencies: { + $0.classBattlePreviewDependencies.audioPlayer = player + } + + await store.send(.view(.onAppear)) { $0.isActive = true } + await store.receive(\.async, .observeTime(instanceID)) + await store.send(.view(.onDisappear)) { $0.isActive = false } + await store.finish() + } + + @Test + func 음원_로딩_중_화면을_떠났다가_돌아오면_로딩을_재시작한다() async throws { + let player = RecordingPreviewAudioPlayer() + let url = try #require(URL(string: "https://example.com/preview.mp3")) + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.scenario = BattleScenario( + battleId: state.battle.id, + title: "미리 듣기", + philosophers: [], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: ["sample": url.absoluteString], + nodes: [] + ) + let instanceID = state.instanceID + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } withDependencies: { + $0.classBattlePreviewDependencies.audioPlayer = player + } + + await store.send(.view(.onAppear)) { $0.isActive = true } + await store.receive(\.async, .loadAudio(url)) + await store.receive(\.inner, .audioLoaded(true, 300)) { + $0.isAudioReady = true + $0.audioDuration = 300 + $0.isPlaying = true + } + await store.receive(\.async, .observeTime(instanceID)) + await store.send(.view(.onDisappear)) { + $0.isActive = false + $0.isPlaying = false + } + await store.finish() + } + + @Test + func 짧은_음원의_마지막_틱에서도_재생_완료로_처리한다() async { + let player = RecordingPreviewAudioPlayer() + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.isActive = true + state.isAudioReady = true + state.isPlaying = true + state.audioDuration = 8 + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } withDependencies: { + $0.classBattlePreviewDependencies.audioPlayer = player + } + + await store.send(.inner(.playerTimeUpdated(7.8))) { + $0.currentTime = 7.8 + $0.isPlaying = false + $0.hasFinished = true + } + await store.finish() + #expect(await player.pauses == 1) + } + + @Test + func 음원이_없는_시나리오는_대사만_표시하고_재생을_비활성화한다() async { + var state = ClassBattlePreviewFeature.State(battle: .mocks[0]) + state.isActive = true + let scenario = BattleScenario( + battleId: state.battle.id, + title: "시나리오 제목", + philosophers: [], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 1, + nodeName: "START", + audioDuration: 120, + autoNextNodeId: nil, + scripts: [ + ScenarioScript( + scriptId: 1, + startTimeMs: 0, + speakerType: .narrator, + speakerName: "화자", + text: "실제 대사" + ), + ], + interactiveOptions: [] + ), + ] + ) + let store = TestStore(initialState: state) { + ClassBattlePreviewFeature() + } + + await store.send(.inner(.scenarioResponse(.success(scenario)))) { + $0.scenario = scenario + $0.viewState = .loaded + $0.audioErrorMessage = "미리 들을 수 있는 음원이 없어요" + } + #expect(store.state.previewScripts.map(\.text) == ["실제 대사"]) + #expect(!store.state.isAudioReady) + } +} + +private actor RecordingPreviewAudioPlayer: AudioPlayerInterface { + private(set) var pauses = 0 + private(set) var seeks: [TimeInterval] = [] + + func load(url _: URL) async -> Bool { + true + } + + func play() async {} + func pause() async { + pauses += 1 + } + + func setPlaybackRate(_: Float) async {} + func seek(to time: TimeInterval) async { + seeks.append(time) + } + + func duration() async -> TimeInterval { + 300 + } + + nonisolated func currentTimes() -> AsyncStream { + AsyncStream { _ in } + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift b/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift index 2d19cfda..7c983202 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift @@ -97,6 +97,19 @@ struct ClassRecommendTests { await store.receive(\.delegate.select, battle) } + @Test + func 미리듣기는_배틀_선택을_바꾸지_않고_선택한_배틀을_전달한다() async { + let battle = ClassBattleSummary.mocks[0] + var state = ClassRecommendFeature.State(filter: .init()) + state.battles = [battle] + state.viewState = .loaded + let store = TestStore(initialState: state) { ClassRecommendFeature() } + + await store.send(.view(.previewTapped(battle.id))) + await store.receive(\.delegate.previewRequested, battle) + #expect(store.state.selectedBattleId == nil) + } + @Test func 선택한_배틀을_다시_누르면_선택이_해제된다() async { var state = ClassRecommendFeature.State(filter: .init()) diff --git a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift index 2181ffbb..731a6e90 100644 --- a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift +++ b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsEvent.swift @@ -39,6 +39,8 @@ public enum AnalyticsEvent: Sendable { case uiAction(action: AnalyticsButton, screen: AnalyticsScreen) /// (Tier3, 선택) 재생 조작. case playbackAction(action: PlaybackAction, contentID: String) + /// 클래스 배틀 미리듣기의 시나리오/음원 준비 실패. + case classPreviewFailed(battleID: Int, reason: String) /// (Tier3, 선택) 미세 상호작용. case engagementAction(action: EngagementAction, targetID: String) } diff --git a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsScreen.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsScreen.swift index 130dc11b..516df761 100644 --- a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsScreen.swift +++ b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsScreen.swift @@ -28,4 +28,6 @@ public enum AnalyticsScreen: String, Sendable { case withdraw case recap case mainTab = "main_tab" + case classRecommend = "class_recommend" + case classBattlePreview = "class_battle_preview" } diff --git a/Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift b/Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift index cca372f6..e1272438 100644 --- a/Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift +++ b/Projects/Service/PickeAnalytics/Sources/UseCase/AnalyticsUseCase+Live.swift @@ -94,7 +94,11 @@ extension AnalyticsUseCase: DependencyKey { captureEventInSentry( name: name, - properties: properties + properties: properties, + isError: { + if case .classPreviewFailed = event { return true } + return false + }() ) // 광고 클릭은 매출과 직결돼 별도 카운터 + 로그로 남긴다(로그는 검색/필터 대상). @@ -112,10 +116,11 @@ extension AnalyticsUseCase: DependencyKey { private static func captureEventInSentry( name: String, - properties: Properties + properties: Properties, + isError: Bool ) { SentrySDK.capture(message: name) { scope in - scope.setLevel(.info) + scope.setLevel(isError ? .error : .info) scope.setTag(value: name, key: "analytics_event") scope.setContext(value: properties.mapValues { $0 as Any }, key: "analytics") } @@ -157,6 +162,7 @@ extension AnalyticsUseCase: DependencyKey { case .uiAction: "ui_action" case .playbackAction: "playback_action" case .engagementAction: "engagement_action" + case .classPreviewFailed: "class_preview_failed" } } @@ -260,6 +266,9 @@ extension AnalyticsUseCase: DependencyKey { case let .engagementAction(action, targetID): return ["action": action.rawValue, "target_id": targetID] + + case let .classPreviewFailed(battleID, reason): + return ["content_id": String(battleID), "reason": reason] } } } diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift index cb9c43fa..cb8e0c32 100644 --- a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift +++ b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift @@ -8,23 +8,27 @@ import SwiftUI /// 화면 상단에 공통으로 올라가는 네비게이션 바. -/// 타이틀은 좌우 콘텐츠 폭과 무관하게 바 전체 기준 절대 중앙에 놓인다(안드로이드 `CustomTopAppBar` 동일). +/// 기본 타이틀은 바 전체 기준 중앙에 놓이고, `leadingTitle`은 뒤로가기 버튼 옆에 놓인다. +/// 두 타이틀을 함께 전달하면 `leadingTitle`이 우선한다. /// 색상은 호출처에서 `.foregroundStyle(.beige50)` 같은 modifier 로 위임. public struct PickeNavigationBar: View { private let onBack: (() -> Void)? private let centerIcon: Image? private let centerTitle: String? + private let leadingTitle: String? private let trailing: () -> Trailing public init( onBack: (() -> Void)? = nil, centerIcon: Image? = nil, centerTitle: String? = nil, + leadingTitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing ) { self.onBack = onBack self.centerIcon = centerIcon self.centerTitle = centerTitle + self.leadingTitle = leadingTitle self.trailing = trailing } @@ -34,6 +38,12 @@ public struct PickeNavigationBar: View { HStack(spacing: 4) { leadingArea + if let leadingTitle { + Text(leadingTitle) + .pretendardFont(.headingMedium) + .kerning(-0.4) + .lineLimit(1) + } Spacer() trailing() } @@ -66,7 +76,7 @@ public struct PickeNavigationBar: View { @ViewBuilder private var centerArea: some View { - if let centerTitle { + if leadingTitle == nil, let centerTitle { Text(centerTitle) .pretendardFont(.headingMedium) .kerning(-0.4) @@ -84,8 +94,14 @@ public extension PickeNavigationBar where Trailing == EmptyView { init( onBack: (() -> Void)? = nil, centerIcon: Image? = nil, - centerTitle: String? = nil + centerTitle: String? = nil, + leadingTitle: String? = nil ) { - self.init(onBack: onBack, centerIcon: centerIcon, centerTitle: centerTitle) { EmptyView() } + self.init( + onBack: onBack, + centerIcon: centerIcon, + centerTitle: centerTitle, + leadingTitle: leadingTitle + ) { EmptyView() } } } diff --git a/docs/class-figma-audit-20261019.md b/docs/class-figma-audit-20261019.md index 13d46123..8649d119 100644 --- a/docs/class-figma-audit-20261019.md +++ b/docs/class-figma-audit-20261019.md @@ -7,9 +7,10 @@ Figma 파일 `6Z49tKXvCCQDd7tyGtvtiI`의 최신 수정 시각은 2026-10-17 08:4 | `12757:19527` | 이용권 등록하기 | `ClassTicketView`의 입력 화면. 402pt 캡처에서 제목·입력·버튼 위치가 대체로 맞는다. | | `13244:49148` | 이용권 등록완료 | `ClassTicketView`의 등록 상태. 402pt 캡처에서 카드와 버튼 위치가 대체로 맞는다. 안내 문구는 Mock 제한을 표시하므로 시안과 다르다. | | `13244:49243` | 이용권 확인하기 | `ClassTicketView`의 확인 상태를 코디네이터 push로 연결했다. 402pt 캡처의 카드·행·버튼은 시안과 가깝고 제목을 16pt로 조정했다. Mock 안내 문구와 클래스 탭 선택은 실제 진입 계약상 다르다. | -| `8661:8290` | 댓글창 확정본 | Chat 댓글 화면의 최신 시각 대조와 캡처 검증이 필요하다. 댓글·답글 API는 변경 대상이 아니다. | +| `8661:8290` | 댓글창 확정본 | 2026-10-21 Chat 댓글 캡처를 다시 대조했다. 주요 구성은 유사하지만 테스트 콘텐츠·프로필 이미지와 상태 표시줄이 달라 완전 일치는 미확인이다. 댓글·답글 API는 변경하지 않았다. | +| `4351:5256` | 대댓글 상세 확정본 | 2026-10-21 캡처의 카드 구조·메뉴 가시성을 확인했다. 동일 콘텐츠 대조가 불가능해 시각 판정은 86점, `revise`다. | | `3928:5099` | 대댓글(이전 링크) | 최신 Figma 파일에서 노드가 조회되지 않는다. 현재 이름이 같은 프레임은 `4351:5256`이며, 클래스 흐름에는 `12799:48394` 대댓글 상세 프레임이 있다. | -| `12772:36346` | 마감일 수정 바텀시트 | `ClassDeadlineEditView`와 공용 `ClassDeadlinePicker`가 바텀시트로 구성된다. 최신 기기 캡처 대조는 남았다. | +| `12772:36346` | 마감일 수정 바텀시트 | 2026-10-19 iPhone 18 Pro 시뮬레이터 캡처에서 시트 높이 334pt, 핸들·휠 선택행·활성 버튼의 위치를 402pt Figma 시안과 대조했다. 배치가 거의 일치한다. | 이용권 캡처는 `ClassTicketVisualAuditTests`의 402×874 결과를 사용했다. Figma 이미지는 2배 배율(804×1748)이며 XCTest 첨부 PNG의 픽셀 크기와 직접 비교하면 가짜 세로 오차가 생긴다. 배율을 정규화한 뒤 시각 비교했다. 캡처의 클래스 탭 선택은 현재 `ClassIntro` 진입 경로와 일치하고, Figma의 마이 탭 선택과는 진입 맥락이 다르다. XCTest는 앱 윈도우만 그리므로 Figma의 시스템 상태 바는 캡처에 나타나지 않는다. 확인 화면의 상단 콘텐츠는 시안보다 약 10~13pt 낮아 실제 앱 진입 캡처에서 추가 확인이 필요하다. @@ -34,4 +35,26 @@ Figma 파일 `6Z49tKXvCCQDd7tyGtvtiI`의 최신 수정 시각은 2026-10-17 08:4 상단 10~13pt 오차는 테스트 호스트와 실제 앱의 안전 영역 차이일 수 있어 이번 판정에서 조건부 수용했다. 실제 앱 캡처에서 같은 오차가 재현되면 수정한다. -다음 수정 우선순위는 실제 앱 진입 화면의 상단 안전 영역 확인, 댓글·답글 UI 캡처, 마감일 수정 시트의 최신 기기 캡처다. 완료 판정은 각각의 캡처와 Figma 확정 시안을 같은 크기로 대조한 뒤 내린다. +마감일 시트는 `Picke-TicketAudit`의 `ClassFlowVisualAuditTests/testCaptureClassDeadlineSheet`에서 기본·변경·오류·탭 진입 상태를 캡처했고, 2026-10-19 iPhone 18 Pro iOS 27.0에서 테스트 1개가 실패 없이 통과했다. Figma `12772:36346`의 402×334pt 시트와 활성 버튼 상태를 같은 pt 배율로 비교했다. 시트 높이, 상단 핸들, 휠 선택행, 16pt 가로 여백과 52pt 버튼 높이는 거의 일치한다. 날짜 문자열은 기기 로캘에 따라 한국어로 바뀌며, XCTest의 앱 윈도우 캡처에는 Figma에 그려진 홈 인디케이터가 나타나지 않는다. 저장 오류 상태에서도 시트가 전체 화면으로 커지지 않았다. + +마감일 시트의 시각 판정 기록: + +```json +{ + "score": 93, + "verdict": "pass", + "category_match": true, + "differences": [ + "Figma의 영어 날짜 표기는 시뮬레이터 로캘에서 한국어로 표시된다.", + "XCTest 앱 윈도우 캡처에는 Figma의 시스템 홈 인디케이터가 나타나지 않는다." + ], + "suggestions": ["실제 앱 진입 캡처에서도 시트 안전 영역을 확인한다."], + "reasoning": "402pt 기준 시트 높이 334pt, 핸들·휠 선택행·버튼 크기와 위치가 확정 시안과 가깝고 오류 상태에서도 같은 시트 높이를 유지한다." +} +``` + +댓글·답글 화면은 `Picke-TicketAudit`의 `CommentFinalDesignTests/testGlobalCommentAndReplyCapture`로 2026-10-21 iPhone 18 Pro iOS 27.0에서 다시 캡처했다. 결과 번들 `/private/tmp/picke-comment-reply-20261021-scroll-assertions-second.xcresult`에는 테스트 1개 통과·실패 0개·건너뜀 0개와 `reply-parent-top`, `reply-middle-row-menu`, `reply-last-row-menu`, `reply-parent-menu` PNG가 있다. 테스트는 중간·마지막 답글과 부모 댓글 메뉴를 각각 연 뒤 수동 스크롤 없이 수정·삭제가 화면에 보이는지 확인한다. 이미 보이는 중간 메뉴는 스크롤 위치가 그대로이고, 마지막 답글 메뉴는 목록 상단에서 열어 스크롤 위치가 내려가는 것도 단언한다. 부모 메뉴는 목록 아래쪽에서도 이미 보일 수 있어 불필요한 이동을 요구하지 않는다. 세 메뉴를 카드 아래 독립 영역에 두어 본문·좋아요·더보기와 겹치지 않는 것을 캡처로 확인했다. Figma `4351:5256`과 비교하면 답글 제목·A/B 배지·더보기·반응 수의 배치 방향은 맞지만, 콘텐츠와 프로필 이미지가 달라 카드 높이와 줄바꿈의 1:1 일치는 확인하지 못했다. Figma에는 열린 메뉴 상태도 없다. 시각 판정은 `.omx/state/comment-reply-20261019/ralph-progress.json`에 86점, `revise`로 기록했다. + +별도 `testParentMenuScrollsIntoViewFromBelowReplies`는 답글 12개로 부모 댓글을 화면 밖에 둔 뒤 메뉴를 열어 스크롤 위치가 위로 이동하고 `수정`·`삭제`가 보이는지 검증했다. `/private/tmp/picke-parent-menu-offscreen-20261021-escalated.xcresult`에서 테스트 1개가 통과했고 `reply-parent-menu-from-bottom` 캡처를 확인했다. 이 캡처는 긴 목록의 열린 메뉴 동작을 증명하며, 닫힌 상태의 Figma 시안과 동일 콘텐츠 비교를 대신하지 않는다. + +다음 수정 우선순위는 실제 앱 진입 화면의 상단 안전 영역과 동일 콘텐츠를 사용한 댓글·답글 캡처 확인이다. 완료 판정은 각각의 캡처와 Figma 확정 시안을 같은 크기로 대조한 뒤 내린다.