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
11 changes: 11 additions & 0 deletions Projects/App/Sources/Navigation/Class/ClassCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions Projects/App/Tests/Sources/ClassCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 91 additions & 1 deletion Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 103 additions & 13 deletions Projects/App/Tests/Sources/CommentFinalDesignTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)))
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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")
}
}

Expand Down
Loading
Loading