Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f2c81df
클래스 중간 투표 API와 분기 재생 흐름 연결
Roy-wonji Oct 28, 2026
adfe235
클래스 생성 대상별 요청 키 분리
Roy-wonji Oct 28, 2026
d99adc0
클래스 API와 홈 디자인 정합성 반영
Roy-wonji Nov 1, 2026
7cead4a
클래스 투표·청취·의견·결과 화면 Figma 정합성 반영
Roy-wonji Nov 2, 2026
f451f73
클래스 Figma 대조용 캡처 테스트와 추출 대상 추가
Roy-wonji Nov 2, 2026
64707f0
클래스 화면 Figma 줄 높이·간격 2차 정합성 반영
Roy-wonji Nov 2, 2026
09b1959
클래스 결과 내 활동 API 연동
Roy-wonji Nov 2, 2026
7f4eec0
의견 중복 신고를 서버 오류 코드로 구분
Roy-wonji Nov 2, 2026
b6eabc0
클래스 리포트와 내가 남긴 의견을 내 활동 API로 연결
Roy-wonji Nov 3, 2026
a3c1716
클래스 의견 더보기 메뉴로 신고·수정·삭제 연결
Roy-wonji Nov 3, 2026
96de226
클래스 의견 메뉴 점검 지적 반영
Roy-wonji Nov 3, 2026
e2a1880
클래스 의견 참여 판정을 내 의견 빈 응답에도 유지
Roy-wonji Nov 3, 2026
fe837df
운영자 대시보드 내 투표 결과 활동 행을 3열로 변경
Roy-wonji Nov 3, 2026
8d23d20
내가 남긴 의견 화면을 닫으면 진행 중인 조회 취소
Roy-wonji Nov 3, 2026
cc95774
클래스 코디네이터 피드백 테스트의 uuid 의존성 오류 수정
Roy-wonji Nov 3, 2026
13367d7
리포트 화면을 닫으면 투표 기록·인사이트 요청도 취소
Roy-wonji Nov 3, 2026
16e1590
운영자 대시보드 내가 남긴 의견 문구를 리포트와 맞춤
Roy-wonji Nov 3, 2026
316f478
내 의견 다시 시도 시 비어 있는 의견 목록도 다시 조회
Roy-wonji Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Projects/App/Sources/Navigation/Class/ClassCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ public struct ClassCoordinator {
effect,
cancelRemovedReportLoads(state: &state),
cancelRemovedChatObservers(state: &state),
cancelRemovedClassEffects(state: &state)
cancelRemovedClassEffects(state: &state),
cancelRemovedMyOpinionsLoads(state: state)
)
}
}
Expand Down Expand Up @@ -462,6 +463,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) }
Expand All @@ -481,6 +483,20 @@ private extension ClassCoordinator {
return .merge(removedIDs.map { .cancel(id: ChatRoomFeature.CancelID.audioObserver($0)) })
}

/// 내 의견 화면은 항상 최상단 하나뿐이고 CancelID가 전역이므로, 라우트에 없으면 남은 조회를 모두 취소한다.
func cancelRemovedMyOpinionsLoads(state: State) -> Effect<Action> {
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<Action> {
let reportIDs = Set(state.routes.compactMap { route -> UUID? in
guard case let .report(report) = route.screen else { return nil }
Expand All @@ -500,14 +516,18 @@ 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
[
.cancel(id: ClassOwnerDashboardFeature.CancelID.load(id)),
.cancel(id: ClassOwnerDashboardFeature.CancelID.generate(id)),
.cancel(id: ClassOwnerDashboardFeature.CancelID.generatePersonal(id)),
.cancel(id: ClassOwnerDashboardFeature.CancelID.myActivity(id)),
]
}
)
Expand Down
25 changes: 22 additions & 3 deletions Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<T: Sendable>(
request: @escaping @Sendable (CheckedContinuation<T, Error>) -> Request
) async throws -> T {
Expand Down
122 changes: 121 additions & 1 deletion Projects/App/Tests/Sources/ClassCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,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]
Expand Down Expand Up @@ -2777,6 +2845,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 {
Expand Down Expand Up @@ -2999,11 +3075,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<ClassResult, Error>] = [:]
private var activityRequests: [Int: CheckedContinuation<ClassMyActivity, Error>] = [:]
private var insightRequests: [Int: CheckedContinuation<ClassAIInsight, Error>] = [:]
private var startedCount = 0
private var startWaiters: [(Int, CheckedContinuation<Void, Never>)] = []
private(set) var cancelledRequestIDs: [Int] = []
Expand Down Expand Up @@ -3033,6 +3118,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)
Expand All @@ -3056,13 +3153,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,
Expand Down
Loading
Loading