Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 82 additions & 0 deletions Projects/App/Sources/Navigation/Class/ClassCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public struct ClassCoordinator {

public enum AsyncAction: Equatable {}
public enum InnerAction: Equatable {
case ticketRegistrationCompleted(routeID: Int, instanceID: UUID, startDate: Date, endDate: Date)
case membersLoaded(
routeID: Int,
roomID: Int,
Expand Down Expand Up @@ -479,6 +480,51 @@ private extension ClassCoordinator {
state.routes.push(.ticket(.init(instanceID: uuid())))
return .none

case let .routeAction(routeID, action: .ticket(.delegate(.showOverview(startDate, endDate)))):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 ClassTicketFeature.swift LINE 62를 참조하세요.

Suggested change
case let .routeAction(routeID, action: .ticket(.delegate(.showOverview(startDate, endDate)))):
case let .routeAction(routeID, action: .ticket(.delegate(.registrationConfirmed(startDate: startDate, endDate: endDate)))):

guard routeID == state.routes.count - 1,
case let .ticket(completed)? = state.routes.last?.screen,
completed.phase == .registered,
completed.registrationStartDate == startDate,
completed.expirationDate == endDate
else { return .none }
return .send(.inner(.ticketRegistrationCompleted(
routeID: routeID,
instanceID: completed.instanceID,
startDate: startDate,
endDate: endDate
)))

case let .routeAction(routeID, action: .ticket(.delegate(.registered(startDate, endDate)))):
guard routeID == state.routes.count - 1,
routeID >= 2,
case let .ticket(registered)? = state.routes.last?.screen,
registered.phase == .registered,
registered.registrationStartDate == startDate,
registered.expirationDate == endDate,
case var .ticket(overview) = state.routes[routeID - 1].screen,
overview.phase == .overview
else { return .none }
overview.registrationStartDate = startDate
overview.expirationDate = endDate
state.routes[screenAt: routeID - 1] = .ticket(overview)
if routeID >= 3,
case var .ticket(original) = state.routes[routeID - 2].screen,
original.phase == .registered
{
original.registrationStartDate = startDate
original.expirationDate = endDate
state.routes[screenAt: routeID - 2] = .ticket(original)
}
return .none

case let .routeAction(routeID, action: .ticket(.delegate(.addRegistration))):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 ClassTicketFeature.swift LINE 63을 참조하세요.

Suggested change
case let .routeAction(routeID, action: .ticket(.delegate(.addRegistration))):
case let .routeAction(routeID, action: .ticket(.delegate(.addRegistrationRequested))):

guard routeID == state.routes.count - 1,
case let .ticket(overview)? = state.routes.last?.screen,
overview.phase == .overview
else { return .none }
state.routes.push(.ticket(.init(instanceID: uuid())))
return .none

case let .routeAction(routeID, action: .ticket(.delegate(.dismiss))):
guard routeID == state.routes.count - 1,
case .ticket = state.routes.last?.screen
Expand Down Expand Up @@ -1028,6 +1074,42 @@ private extension ClassCoordinator {
action: InnerAction
) -> Effect<Action> {
switch action {
case let .ticketRegistrationCompleted(routeID, instanceID, startDate, endDate):
guard routeID == state.routes.count - 1,
case let .ticket(completed)? = state.routes.last?.screen,
completed.instanceID == instanceID,
completed.phase == .registered,
completed.registrationStartDate == startDate,
completed.expirationDate == endDate
else { return .none }

if routeID >= 2,
case let .ticket(previous) = state.routes[routeID - 1].screen,
previous.phase == .overview
{
state.routes.goBack()
var overview = previous
overview.registrationStartDate = startDate
overview.expirationDate = endDate
state.routes[screenAt: routeID - 1] = .ticket(overview)
if routeID >= 3,
case var .ticket(original) = state.routes[routeID - 2].screen,
original.phase == .registered
{
original.registrationStartDate = startDate
original.expirationDate = endDate
state.routes[screenAt: routeID - 2] = .ticket(original)
}
return .none
}

var overview = ClassTicketFeature.State(instanceID: uuid())
overview.phase = .overview
overview.registrationStartDate = startDate
overview.expirationDate = endDate
state.routes.push(.ticket(overview))
return .none

case let .joinedSessionCleared(requestID, room):
guard state.pendingJoinID == requestID,
isCompletingJoinPrefix(state)
Expand Down
196 changes: 196 additions & 0 deletions Projects/App/Tests/Sources/ClassCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,114 @@ struct ClassParticipantPushTests {
}
}

@Test
func 이용권_확인은_push하고_재등록은_기존_확인_경로를_갱신한다() async throws {
let firstStart = try #require("2026.10.09".date(as: .yearMonthDayDotted))
let firstEnd = try #require("2027.04.08".date(as: .yearMonthDayDotted))
let secondStart = try #require("2026.11.01".date(as: .yearMonthDayDotted))
let secondEnd = try #require("2027.04.30".date(as: .yearMonthDayDotted))
let clock = TestClock()
let originalID = UUID(400)
var registration = ClassTicketFeature.State(instanceID: originalID)
registration.phase = .registered
registration.registrationStartDate = firstStart
registration.expirationDate = firstEnd
var state = ClassCoordinator.State()
state.routes.push(.ticket(registration))
state.ticketInstanceIDs = [originalID]
let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: {
$0.uuid = .incrementing
$0.continuousClock = clock
$0.date = .constant(secondStart)
}

await store.send(.router(.routeAction(
id: 1,
action: .ticket(.delegate(.showOverview(firstStart, firstEnd)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 ClassTicketFeature.swift LINE 62를 참조하세요.

Suggested change
action: .ticket(.delegate(.showOverview(firstStart, firstEnd)))
action: .ticket(.delegate(.registrationConfirmed(startDate: firstStart, endDate: firstEnd)))

)))
await store.receive(\.inner) {
var overview = ClassTicketFeature.State(instanceID: UUID(0))
overview.phase = .overview
overview.registrationStartDate = firstStart
overview.expirationDate = firstEnd
$0.routes.push(.ticket(overview))
$0.ticketInstanceIDs = [originalID, UUID(0)]
}
#expect(store.state.showsTabBar)

await store.send(.router(.routeAction(
id: 2,
action: .ticket(.delegate(.addRegistration))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 ClassTicketFeature.swift LINE 63을 참조하세요.

Suggested change
action: .ticket(.delegate(.addRegistration))
action: .ticket(.delegate(.addRegistrationRequested))

))) {
$0.routes.push(.ticket(.init(instanceID: UUID(1))))
$0.ticketInstanceIDs = [originalID, UUID(0), UUID(1)]
}
await store.send(.router(.routeAction(
id: 3,
action: .ticket(.binding(.set(\.code, "PICKE30")))
))) {
guard case var .ticket(ticket) = $0.routes[3].screen else { return }
ticket.code = "PICKE30"
$0.routes[screenAt: 3] = .ticket(ticket)
}
await store.send(.router(.routeAction(
id: 3,
action: .ticket(.view(.checkTapped))
))) {
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.phase = .registered
ticket.registrationStartDate = secondStart
ticket.expirationDate = secondEnd
$0.routes[screenAt: 3] = .ticket(ticket)
}
await store.receive(\.router.routeAction) {
guard case var .ticket(overview) = $0.routes[2].screen,
case var .ticket(original) = $0.routes[1].screen
else { return }
overview.registrationStartDate = secondStart
overview.expirationDate = secondEnd
original.registrationStartDate = secondStart
original.expirationDate = secondEnd
$0.routes[screenAt: 2] = .ticket(overview)
$0.routes[screenAt: 1] = .ticket(original)
}
await store.send(.router(.routeAction(
id: 3,
action: .ticket(.delegate(.showOverview(secondStart, secondEnd)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 ClassTicketFeature.swift LINE 62를 참조하세요.

Suggested change
action: .ticket(.delegate(.showOverview(secondStart, secondEnd)))
action: .ticket(.delegate(.registrationConfirmed(startDate: secondStart, endDate: secondEnd)))

)))
#expect(store.state.routes.count == 4)
await store.receive(\.inner) {
$0.routes.goBack()
guard case var .ticket(overview) = $0.routes[2].screen,
case var .ticket(original) = $0.routes[1].screen
else { return }
overview.registrationStartDate = secondStart
overview.expirationDate = secondEnd
original.registrationStartDate = secondStart
original.expirationDate = secondEnd
$0.routes[screenAt: 2] = .ticket(overview)
$0.routes[screenAt: 1] = .ticket(original)
$0.ticketInstanceIDs = [originalID, UUID(0)]
}
#expect(store.state.routes.count == 3)
await store.send(.view(.backAction)) {
$0.routes.goBack()
$0.ticketInstanceIDs = [originalID]
}
guard case let .ticket(original) = store.state.routes.last?.screen else {
Issue.record("뒤로 이동 후 등록 화면이 사라졌습니다")
return
}
#expect(original.periodText == "2026.11.01 – 2027.04.30")
}

@Test
func 이용권_확인_중_스와이프로_경로가_제거되면_응답도_취소된다() async {
let clock = TestClock()
Expand Down Expand Up @@ -1946,6 +2054,94 @@ struct ClassParticipantPushTests {
await store.finish(timeout: .seconds(1))
}

@Test
func 추가등록_성공_후_뒤로가도_확인_화면에_새_기간이_남는다() async throws {
let oldStart = try #require("2026.10.09".date(as: .yearMonthDayDotted))
let oldEnd = try #require("2027.04.08".date(as: .yearMonthDayDotted))
let newStart = try #require("2026.11.01".date(as: .yearMonthDayDotted))
let newEnd = try #require("2027.04.30".date(as: .yearMonthDayDotted))
var original = ClassTicketFeature.State(instanceID: UUID(400))
original.phase = .registered
original.registrationStartDate = oldStart
original.expirationDate = oldEnd
var overview = ClassTicketFeature.State(instanceID: UUID(401))
overview.phase = .overview
overview.registrationStartDate = oldStart
overview.expirationDate = oldEnd
var registration = ClassTicketFeature.State(instanceID: UUID(402))
registration.phase = .registered
registration.registrationStartDate = newStart
registration.expirationDate = newEnd
var state = ClassCoordinator.State()
state.routes.push(.ticket(original))
state.routes.push(.ticket(overview))
state.routes.push(.ticket(registration))
state.ticketInstanceIDs = [original.instanceID, overview.instanceID, registration.instanceID]
let store = TestStore(initialState: state) { ClassCoordinator() }

await store.send(.router(.routeAction(
id: 3,
action: .ticket(.delegate(.registered(newStart, newEnd)))
))) {
guard case var .ticket(currentOverview) = $0.routes[2].screen,
case var .ticket(currentOriginal) = $0.routes[1].screen
else { return }
currentOverview.registrationStartDate = newStart
currentOverview.expirationDate = newEnd
currentOriginal.registrationStartDate = newStart
currentOriginal.expirationDate = newEnd
$0.routes[screenAt: 2] = .ticket(currentOverview)
$0.routes[screenAt: 1] = .ticket(currentOriginal)
}
await store.send(.view(.backAction)) {
$0.routes.goBack()
$0.ticketInstanceIDs = [original.instanceID, overview.instanceID]
}
guard case let .ticket(currentOverview) = store.state.routes.last?.screen else {
Issue.record("추가등록 뒤 확인 화면이 사라졌습니다")
return
}
#expect(currentOverview.periodText == "2026.11.01 – 2027.04.30")
}

@Test
func 추가등록_조회_중_경로가_제거되면_응답을_취소한다() async throws {
let start = try #require("2026.10.09".date(as: .yearMonthDayDotted))
let end = try #require("2027.04.08".date(as: .yearMonthDayDotted))
let clock = TestClock()
var original = ClassTicketFeature.State(instanceID: UUID(400))
original.phase = .registered
original.registrationStartDate = start
original.expirationDate = end
var overview = ClassTicketFeature.State(instanceID: UUID(401))
overview.phase = .overview
overview.registrationStartDate = start
overview.expirationDate = end
var registration = ClassTicketFeature.State(instanceID: UUID(402))
registration.code = "PICKE30"
var state = ClassCoordinator.State()
state.routes.push(.ticket(original))
state.routes.push(.ticket(overview))
state.routes.push(.ticket(registration))
state.ticketInstanceIDs = [original.instanceID, overview.instanceID, registration.instanceID]
let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: {
$0.continuousClock = clock
$0.date = .constant(start)
}

await store.send(.router(.routeAction(id: 3, action: .ticket(.view(.checkTapped))))) {
guard case var .ticket(current) = $0.routes[3].screen else { return }
current.isChecking = true
$0.routes[screenAt: 3] = .ticket(current)
}
await store.send(.router(.updateRoutes(Array(state.routes.dropLast())))) {
$0.routes = Array($0.routes.dropLast())
$0.ticketInstanceIDs = [original.instanceID, overview.instanceID]
}
await clock.advance(by: .milliseconds(350))
await store.finish(timeout: .seconds(1))
}

@Test
func 인트로_참여는_코드_화면을_한번만_push한다() async {
let codeID = UUID(201)
Expand Down
3 changes: 3 additions & 0 deletions Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ final class ClassTicketVisualAuditTests: XCTestCase {
registered.phase = .registered
registered.registrationStartDate = try XCTUnwrap("2026.10.09".date(as: .yearMonthDayDotted))
registered.expirationDate = try XCTUnwrap("2027.04.08".date(as: .yearMonthDayDotted))
var overview = registered
overview.phase = .overview
for (width, height) in [(402, 874), (375, 812)] {
let size = CGSize(width: width, height: height)
try await capture(entry, name: "ticket-entry-\(width)", size: size)
try await capture(registered, name: "ticket-registered-\(width)", size: size)
try await capture(overview, name: "ticket-overview-\(width)", size: size)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import Foundation
public enum ClassTicketPhase: Equatable, Sendable {
case entry
case registered
case overview
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public struct ClassTicketFeature {
!code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isChecking
}

public var navigationTitle: String {
phase == .overview ? "클래스 이용권" : "이용권 등록하기"
}

public var periodText: String {
guard let registrationStartDate, let expirationDate else { return "" }
return "\(registrationStartDate.formatted(.yearMonthDayDotted)) – \(expirationDate.formatted(.yearMonthDayDotted))"
Expand All @@ -48,12 +52,16 @@ public struct ClassTicketFeature {
case backTapped
case checkTapped
case confirmTapped
case addRegistrationTapped
case faqTapped(Int)
}

@CasePathable
public enum DelegateAction: Equatable {
case dismiss
case showOverview(Date, Date)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션은 자식 Reducer에서 발생한 이벤트를 명확히 설명해야 합니다. showOverview는 부모 Reducer에 특정 효과를 요청하는 것으로 해석될 수 있어 TCA 액션 네이밍 컨벤션에 맞지 않습니다. showOverview가 등록 완료 후 확인 화면을 보여주는 용도라면, registrationConfirmed와 같이 이벤트 발생을 알리는 형태로 변경하는 것이 좋습니다.

Suggested change
case showOverview(Date, Date)
case registrationConfirmed(startDate: Date, endDate: Date)

case addRegistration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션은 자식 Reducer에서 발생한 이벤트를 명확히 설명해야 합니다. addRegistration은 부모 Reducer에 특정 효과를 요청하는 것으로 해석될 수 있어 TCA 액션 네이밍 컨벤션에 맞지 않습니다. 추가 등록을 요청하는 의도를 명확히 하려면 addRegistrationRequested와 같이 변경하는 것이 좋습니다.

Suggested change
case addRegistration
case addRegistrationRequested

case registered(Date, Date)
}

public enum CancelID: Hashable, Sendable {
Expand All @@ -76,9 +84,21 @@ public struct ClassTicketFeature {
case .binding:
return .none

case .view(.backTapped), .view(.confirmTapped):
case .view(.backTapped):
return .send(.delegate(.dismiss))

case .view(.confirmTapped):
guard
state.phase == .registered,
let registrationStartDate = state.registrationStartDate,
let expirationDate = state.expirationDate
else { return .none }
return .send(.delegate(.showOverview(registrationStartDate, expirationDate)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 LINE 62를 참조하세요.

Suggested change
return .send(.delegate(.showOverview(registrationStartDate, expirationDate)))
return .send(.delegate(.registrationConfirmed(startDate: registrationStartDate, endDate: expirationDate)))


case .view(.addRegistrationTapped):
guard state.phase == .overview else { return .none }
return .send(.delegate(.addRegistration))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

Delegate 액션의 네이밍 컨벤션에 따라 액션을 수정해야 합니다. 관련 수정 제안은 LINE 63을 참조하세요.

Suggested change
return .send(.delegate(.addRegistration))
return .send(.delegate(.addRegistrationRequested))


case let .view(.faqTapped(index)):
state.expandedFAQ = state.expandedFAQ == index ? nil : index
return .none
Expand Down Expand Up @@ -111,6 +131,7 @@ public struct ClassTicketFeature {
state.registrationStartDate = start
state.expirationDate = lastDay
state.phase = .registered
return .send(.delegate(.registered(start, lastDay)))

case .failure:
state.errorMessage = "유효하지 않은 이용권 코드예요. 다시 확인해 주세요."
Expand Down
Loading
Loading