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
25 changes: 24 additions & 1 deletion Projects/App/Sources/Navigation/Class/ClassCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public struct ClassCoordinator {
var participantJoinInstanceIDs: Set<UUID> = []
var memberInstanceIDs: Set<UUID> = []
var aiVoteInstanceIDs: Set<UUID> = []
var ticketInstanceIDs: Set<UUID> = []

public init() {
routes = [.root(.intro(.init()), embedInNavigationView: true)]
Expand All @@ -61,7 +62,7 @@ public struct ClassCoordinator {
&& members.customAlert == nil && !members.isSaving
case let .ownerDashboard(dashboard)?:
return dashboard.management == nil
case .report?, .myOpinions?:
case .report?, .myOpinions?, .ticket?:
return true
default:
return false
Expand Down Expand Up @@ -378,14 +379,20 @@ private extension ClassCoordinator {
guard case let .aiVote(vote) = route.screen else { return nil }
return vote.instanceID
})
let ticketIDs = Set(state.routes.compactMap { route -> UUID? in
guard case let .ticket(ticket) = route.screen else { return nil }
return ticket.instanceID
})
let removedOwners = state.ownerNameInstanceIDs.subtracting(ownerIDs)
let removedJoins = state.participantJoinInstanceIDs.subtracting(joinIDs)
let removedMembers = state.memberInstanceIDs.subtracting(memberIDs)
let removedVotes = state.aiVoteInstanceIDs.subtracting(voteIDs)
let removedTickets = state.ticketInstanceIDs.subtracting(ticketIDs)
state.ownerNameInstanceIDs = ownerIDs
state.participantJoinInstanceIDs = joinIDs
state.memberInstanceIDs = memberIDs
state.aiVoteInstanceIDs = voteIDs
state.ticketInstanceIDs = ticketIDs
return .merge(
removedOwners.map { .cancel(id: ClassJoinFeature.CancelID.nameOwner($0)) }
+ removedJoins.flatMap { id in
Expand All @@ -401,6 +408,7 @@ private extension ClassCoordinator {
.cancel(id: ClassAIVoteFeature.CancelID.submit(id)),
]
}
+ removedTickets.map { .cancel(id: ClassTicketFeature.CancelID.check($0)) }
)
}

Expand Down Expand Up @@ -463,6 +471,20 @@ private extension ClassCoordinator {
state.routes.push(.join(.init(mode: .code, instanceID: uuid())))
return .none

case let .routeAction(routeID, action: .intro(.delegate(.ticket))):
guard routeID == 0, state.routes.count == 1,
case .intro = state.routes[0].screen
else { return .none }
invalidateCompletingJoin(state: &state)
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
else { return .none }
return .send(.view(.backAction))

case let .routeAction(routeID, action: .join(.delegate(.found(room, instanceID: instanceID)))):
guard routeID == state.routes.count - 1,
let instanceID,
Expand Down Expand Up @@ -1120,6 +1142,7 @@ extension ClassCoordinator {
@Reducer
public enum ClassScreen {
case intro(ClassIntroFeature)
case ticket(ClassTicketFeature)
case join(ClassJoinFeature)
case startMethod(ClassStartMethodFeature)
case topic(ClassTopicFeature)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public struct ClassCoordinatorView: View {
case let .intro(introStore):
ClassIntroView(store: introStore)

case let .ticket(ticketStore):
ClassTicketView(store: ticketStore)

case let .join(joinStore):
ClassJoinView(store: joinStore)

Expand Down
59 changes: 59 additions & 0 deletions Projects/App/Tests/Sources/ChatReplyNavigationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// ChatReplyNavigationTests.swift
// PickeTests
//
// Created by Picke on 10/19/26.
//

import CommentDomainInterface
import ComposableArchitecture
import TCAFlow
import Testing

@testable import Picke

@MainActor
struct ChatReplyNavigationTests {
@Test
func 클래스_댓글에서_답글을_열고_뒤로가면_댓글로_돌아온다() async {
let parent = CommentItem(
perspectiveId: 42,
author: "작성자",
timeAgo: "방금 전",
option: .a,
content: "부모 의견",
replyCount: 1,
likeCount: 0,
createdOrder: 0
)
let store = TestStore(initialState: ChatCoordinator.State(commentBattleId: 7)) {
ChatCoordinator()
}
store.exhaustivity = .off

await store.send(.router(.routeAction(
id: 0,
action: .comment(.delegate(.openReply(parent)))
)))
#expect(store.state.routes.count == 2)
guard case let .commentReply(reply)? = store.state.routes.last?.screen else {
Issue.record("답글 화면으로 push되지 않음")
return
}
#expect(reply.perspectiveId == 42)

await store.send(.router(.routeAction(
id: 1,
action: .commentReply(.delegate(.dismiss))
)))
await store.receive {
guard case .view(.backAction) = $0 else { return false }
return true
}
#expect(store.state.routes.count == 1)
guard case .comment = store.state.routes.last?.screen else {
Issue.record("댓글 화면으로 돌아오지 않음")
return
}
}
}
49 changes: 49 additions & 0 deletions Projects/App/Tests/Sources/ClassCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,55 @@ struct ClassNavigationLoadingTests {

@MainActor
struct ClassParticipantPushTests {
@Test
func 인트로_이용권은_한번만_push하고_닫으면_돌아간다() async {
let ticketID = UUID(301)
let store = TestStore(initialState: ClassCoordinator.State()) { ClassCoordinator() } withDependencies: {
$0.uuid = .constant(ticketID)
}

await store.send(.router(.routeAction(id: 0, action: .intro(.delegate(.ticket))))) {
$0.routes.push(.ticket(.init(instanceID: ticketID)))
$0.ticketInstanceIDs = [ticketID]
}
#expect(store.state.showsTabBar)
await store.send(.router(.routeAction(id: 0, action: .intro(.delegate(.ticket)))))
#expect(store.state.routes.count == 2)

await store.send(.router(.routeAction(id: 1, action: .ticket(.delegate(.dismiss)))))
await store.receive(\.view.backAction) {
$0.routes.goBack()
$0.ticketInstanceIDs = []
}
}

@Test
func 이용권_확인_중_스와이프로_경로가_제거되면_응답도_취소된다() async {
let clock = TestClock()
let ticketID = UUID(302)
var ticket = ClassTicketFeature.State(instanceID: ticketID)
ticket.code = "PICKE30"
var state = ClassCoordinator.State()
state.routes.push(.ticket(ticket))
state.ticketInstanceIDs = [ticketID]
let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: {
$0.continuousClock = clock
$0.date = .constant(Date(timeIntervalSince1970: 0))
}

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

@Test
func 인트로_참여는_코드_화면을_한번만_push한다() async {
let codeID = UUID(201)
Expand Down
67 changes: 67 additions & 0 deletions Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import ComposableArchitecture
import DomainAssembly
import FeatureAssembly
import PickeCoreUtility
@testable import Picke
import SwiftUI
import TCAFlow
import UIKit
import XCTest

final class ClassTicketVisualAuditTests: XCTestCase {
@MainActor
func testCaptureTicketEntryAndRegisteredScreens() async throws {
guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else {
throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture ticket screens")
}

let entry = ClassTicketFeature.State()

var registered = ClassTicketFeature.State()
registered.phase = .registered
registered.registrationStartDate = try XCTUnwrap("2026.10.09".date(as: .yearMonthDayDotted))
registered.expirationDate = try XCTUnwrap("2027.04.08".date(as: .yearMonthDayDotted))
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)
}
}

@MainActor
private func capture(
_ ticket: ClassTicketFeature.State,
name: String,
size: CGSize
) async throws {
var state = MainTabCoordinator.State(selectedTab: MainTabCoordinator.Tab.classroom.rawValue)
state.classState.routes.push(.ticket(ticket))
let repository = MockClassRepository(rooms: [], availableRooms: [])
let store = Store(initialState: state) {
MainTabCoordinator()
.dependency(\.classUseCase, repository)
.dependency(\.classMockRepository, repository)
}
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.frame = CGRect(origin: .zero, size: size)
window.rootViewController = UIHostingController(rootView: MainTabView(store: store))
window.makeKeyAndVisible()
defer { window.isHidden = true }

try await Task.sleep(for: .milliseconds(500))
window.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let directory = try FileManager.default.url(
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
).appendingPathComponent("ClassVisualAudit", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent(name + ".png"))
let attachment = XCTAttachment(image: image)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
82 changes: 82 additions & 0 deletions Projects/App/Tests/Sources/CommentFinalDesignTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import BattleDomainInterface
import CommentDomainInterface
import ComposableArchitecture
import FeatureAssembly
@testable import Picke
Expand All @@ -17,6 +18,62 @@ import XCTest

/// Opt-in captures of the mounted global comment and reply screens for design review.
final class CommentFinalDesignTests: XCTestCase {
@MainActor
func testReplyEditTakesInputFocus() async throws {
guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else {
throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to verify reply input focus")
}

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
_ = try await perspectiveAPI.createComment(
perspectiveId: perspective.perspectiveId,
content: "수정할 답글입니다."
)

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("reply loaded for focus") {
!store.isLoadingDetail && !store.isLoadingReplies && !store.replies.isEmpty
}

let reply = try XCTUnwrap(store.replies.first)
store.send(.view(.replyMenu(id: reply.id, action: .edit)))
try await waitFor("reply edit first responder") { hasFocusedInput(in: window) }

window.endEditing(true)
try await waitFor("reply focus released") { !hasFocusedInput(in: window) }
store.send(.view(.parentMenu(.edit)))
try await waitFor("parent edit first responder") { hasFocusedInput(in: window) }
XCTAssertTrue(store.editingParent)

window.endEditing(true)
try await waitFor("parent focus released") { !hasFocusedInput(in: window) }
store.send(.binding(.set(\CommentReplyFeature.State.editingParent, false)))
store.send(.view(.replyMenu(id: reply.id, action: .edit)))
try await waitFor("reply focus after parent edit") { hasFocusedInput(in: window) }
XCTAssertFalse(store.editingParent)
}

@MainActor
func testGlobalCommentAndReplyCapture() async throws {
guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else {
Expand Down Expand Up @@ -121,13 +178,26 @@ final class CommentFinalDesignTests: XCTestCase {
replyStore.send(.view(.replyMenu(id: lastReply.id, action: .edit)))
XCTAssertEqual(replyStore.editingCommentId, lastReply.commentId)
XCTAssertEqual(replyStore.replyText, lastReply.content)
try await waitFor("reply edit focus") { hasFocusedInput(in: replyWindow) }
try await capture(replyWindow, name: "reply-edit")

replyStore.send(.view(.parentMenu(.more)))
XCTAssertTrue(replyStore.parentMenuOpen)
scrollToTop(replyScroll)
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)

replyWindow.endEditing(true)
try await waitFor("parent focus released before reply edit") { !hasFocusedInput(in: replyWindow) }
replyStore.send(.binding(.set(\CommentReplyFeature.State.editingParent, false)))
replyStore.send(.view(.replyMenu(id: lastReply.id, action: .edit)))
try await waitFor("reply focus after parent edit") { hasFocusedInput(in: replyWindow) }
XCTAssertFalse(replyStore.editingParent)
}

@MainActor
Expand Down Expand Up @@ -185,6 +255,18 @@ final class CommentFinalDesignTests: XCTestCase {
return window.rootViewController.map { containsInput($0.view) } ?? false
}

@MainActor
private func hasFocusedInput(in window: UIWindow) -> Bool {
func containsFocusedInput(_ view: UIView) -> Bool {
if (view is UITextField || view is UITextView), view.window === window, view.isFirstResponder {
return true
}
return view.subviews.contains(where: containsFocusedInput)
}
window.layoutIfNeeded()
return window.rootViewController.map { containsFocusedInput($0.view) } ?? false
}

@MainActor
private func verticalScrollView(in window: UIWindow) -> UIScrollView? {
func all(in view: UIView) -> [UIScrollView] {
Expand Down
Loading
Loading