From 4d2e25dd7a0c56a9353b9c9ef0d634d8be8b635c Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 19 Oct 2026 22:53:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=EA=B6=8C=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20=EC=9E=AC?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B2=BD=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확인 화면과 추가 등록 화면을 코디네이터 push로 연결 - 재등록 후 기존 이용 기간을 갱신하고 경로 제거 응답을 검증 - Figma 대조 기록과 화면 캡처 테스트 추가 --- .../Navigation/Class/ClassCoordinator.swift | 82 ++++++++ .../Tests/Sources/ClassCoordinatorTests.swift | 196 ++++++++++++++++++ .../Sources/ClassTicketVisualAuditTests.swift | 3 + .../ClassTicket/Model/ClassTicketPhase.swift | 1 + .../Reducer/ClassTicketFeature.swift | 23 +- .../ClassTicket/View/ClassTicketView.swift | 42 +++- .../Tests/Sources/ClassTicketTests.swift | 59 ++++++ docs/class-figma-audit-20261019.md | 37 ++++ 8 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 docs/class-figma-audit-20261019.md diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift index 46986aae..e31e72ab 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -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, @@ -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)))): + 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))): + 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 @@ -1028,6 +1074,42 @@ private extension ClassCoordinator { action: InnerAction ) -> Effect { 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) diff --git a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift index 8b7f1577..63c26ec2 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift @@ -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))) + ))) + 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)) + ))) { + $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))) + ))) + #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() @@ -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) diff --git a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift index a9ccbd97..5b166307 100644 --- a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift @@ -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) } } diff --git a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift index 4fe38a48..a83bebd1 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift @@ -3,4 +3,5 @@ import Foundation public enum ClassTicketPhase: Equatable, Sendable { case entry case registered + case overview } diff --git a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift index 27bf8abf..1763c885 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift @@ -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))" @@ -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) + case addRegistration + case registered(Date, Date) } public enum CancelID: Hashable, Sendable { @@ -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))) + + case .view(.addRegistrationTapped): + guard state.phase == .overview else { return .none } + return .send(.delegate(.addRegistration)) + case let .view(.faqTapped(index)): state.expandedFAQ = state.expandedFAQ == index ? nil : index return .none @@ -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 = "유효하지 않은 이용권 코드예요. 다시 확인해 주세요." diff --git a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift index 2f887533..70839039 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift @@ -19,16 +19,19 @@ public struct ClassTicketView: View { VStack(spacing: 0) { PickeNavigationBar( onBack: { send(.backTapped) }, - centerTitle: "이용권 등록하기" + centerTitle: store.navigationTitle ) .foregroundStyle(.gray800) ScrollView { VStack(alignment: .leading, spacing: 0) { - if store.phase == .entry { + switch store.phase { + case .entry: entryContent() - } else { + case .registered: registeredContent() + case .overview: + overviewContent() } } .frame(maxWidth: .infinity) @@ -180,6 +183,39 @@ private extension ClassTicketView { .frame(maxWidth: .infinity) } + @ViewBuilder + func overviewContent() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("사용 중인 이용권") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.gray800) + .padding(.bottom, 16) + + ticketCard() + + overviewBottomSection() + .padding(.top, 40) + } + .padding(.horizontal, 16) + .padding(.top, 20) + } + + @ViewBuilder + func overviewBottomSection() -> some View { + VStack(spacing: 10) { + Text("체험용 이용권이며 마이페이지에 저장되지 않아요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) + + Button("이용권 추가등록하기") { + send(.addRegistrationTapped) + } + .ctaButtonStyle(.primary, size: .large, height: 52) + .accessibilityIdentifier("class-ticket-add-registration") + } + } + @ViewBuilder func ticketCard() -> some View { VStack(spacing: 0) { diff --git a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift index cdc35a9d..4e8f54b4 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift @@ -32,6 +32,10 @@ struct ClassTicketTests { $0.registrationStartDate = registeredAt $0.expirationDate = "2027.04.08".date(as: .yearMonthDayDotted) } + await store.receive(\.delegate, .registered( + registeredAt, + try #require("2027.04.08".date(as: .yearMonthDayDotted)) + )) #expect(store.state.periodText == "2026.10.09 – 2027.04.08") } @@ -103,6 +107,10 @@ struct ClassTicketTests { $0.registrationStartDate = registeredAt $0.expirationDate = end.date(as: .yearMonthDayDotted) } + await store.receive(\.delegate, .registered( + registeredAt, + try #require(end.date(as: .yearMonthDayDotted)) + )) #expect(store.state.periodText == "\(start) – \(end)") } } @@ -134,6 +142,57 @@ struct ClassTicketTests { $0.registrationStartDate = "2026.10.18".date(as: .yearMonthDayDotted) $0.expirationDate = "2027.04.17".date(as: .yearMonthDayDotted) } + await store.receive(\.delegate, .registered( + try #require("2026.10.18".date(as: .yearMonthDayDotted)), + try #require("2027.04.17".date(as: .yearMonthDayDotted)) + )) #expect(store.state.periodText == "2026.10.18 – 2027.04.17") } + + @Test + func 등록_완료_확인은_이용_기간을_개요_경로로_전달한다() async throws { + let start = try #require("2026.10.09".date(as: .yearMonthDayDotted)) + let end = try #require("2027.04.08".date(as: .yearMonthDayDotted)) + var state = ClassTicketFeature.State() + state.phase = .registered + state.registrationStartDate = start + state.expirationDate = end + let store = TestStore(initialState: state) { ClassTicketFeature() } + + await store.send(.view(.confirmTapped)) + await store.receive(\.delegate, .showOverview(start, end)) + #expect(store.state.navigationTitle == "이용권 등록하기") + } + + @Test + func 미등록_또는_기간이_없는_상태에서는_개요로_이동하지_않는다() async throws { + let start = try #require("2026.10.09".date(as: .yearMonthDayDotted)) + for phase in [ClassTicketPhase.entry, .registered, .overview] { + var state = ClassTicketFeature.State() + state.phase = phase + state.registrationStartDate = start + let store = TestStore(initialState: state) { ClassTicketFeature() } + + await store.send(.view(.confirmTapped)) + await store.finish() + } + } + + @Test + func 개요에서만_추가등록을_요청하고_뒤로가기는_별도_종료를_요청한다() async throws { + let start = try #require("2026.10.09".date(as: .yearMonthDayDotted)) + let end = try #require("2027.04.08".date(as: .yearMonthDayDotted)) + var state = ClassTicketFeature.State() + state.phase = .overview + state.registrationStartDate = start + state.expirationDate = end + let store = TestStore(initialState: state) { ClassTicketFeature() } + + #expect(store.state.navigationTitle == "클래스 이용권") + #expect(store.state.periodText == "2026.10.09 – 2027.04.08") + await store.send(.view(.addRegistrationTapped)) + await store.receive(\.delegate, .addRegistration) + await store.send(.view(.backTapped)) + await store.receive(\.delegate, .dismiss) + } } diff --git a/docs/class-figma-audit-20261019.md b/docs/class-figma-audit-20261019.md new file mode 100644 index 00000000..13d46123 --- /dev/null +++ b/docs/class-figma-audit-20261019.md @@ -0,0 +1,37 @@ +# 클래스·이용권 확정 시안 대조 (2026-10-19) + +Figma 파일 `6Z49tKXvCCQDd7tyGtvtiI`의 최신 수정 시각은 2026-10-17 08:40 UTC다. 아래는 Figma API로 조회한 현재 노드와 프로젝트 화면을 대조한 결과다. 코드와 화면이 완전히 일치한다는 판정은 아니다. + +| Figma 노드 | 확정 시안 | 현재 구현 / 확인 결과 | +| --- | --- | --- | +| `12757:19527` | 이용권 등록하기 | `ClassTicketView`의 입력 화면. 402pt 캡처에서 제목·입력·버튼 위치가 대체로 맞는다. | +| `13244:49148` | 이용권 등록완료 | `ClassTicketView`의 등록 상태. 402pt 캡처에서 카드와 버튼 위치가 대체로 맞는다. 안내 문구는 Mock 제한을 표시하므로 시안과 다르다. | +| `13244:49243` | 이용권 확인하기 | `ClassTicketView`의 확인 상태를 코디네이터 push로 연결했다. 402pt 캡처의 카드·행·버튼은 시안과 가깝고 제목을 16pt로 조정했다. Mock 안내 문구와 클래스 탭 선택은 실제 진입 계약상 다르다. | +| `8661:8290` | 댓글창 확정본 | Chat 댓글 화면의 최신 시각 대조와 캡처 검증이 필요하다. 댓글·답글 API는 변경 대상이 아니다. | +| `3928:5099` | 대댓글(이전 링크) | 최신 Figma 파일에서 노드가 조회되지 않는다. 현재 이름이 같은 프레임은 `4351:5256`이며, 클래스 흐름에는 `12799:48394` 대댓글 상세 프레임이 있다. | +| `12772:36346` | 마감일 수정 바텀시트 | `ClassDeadlineEditView`와 공용 `ClassDeadlinePicker`가 바텀시트로 구성된다. 최신 기기 캡처 대조는 남았다. | + +이용권 캡처는 `ClassTicketVisualAuditTests`의 402×874 결과를 사용했다. Figma 이미지는 2배 배율(804×1748)이며 XCTest 첨부 PNG의 픽셀 크기와 직접 비교하면 가짜 세로 오차가 생긴다. 배율을 정규화한 뒤 시각 비교했다. 캡처의 클래스 탭 선택은 현재 `ClassIntro` 진입 경로와 일치하고, Figma의 마이 탭 선택과는 진입 맥락이 다르다. XCTest는 앱 윈도우만 그리므로 Figma의 시스템 상태 바는 캡처에 나타나지 않는다. 확인 화면의 상단 콘텐츠는 시안보다 약 10~13pt 낮아 실제 앱 진입 캡처에서 추가 확인이 필요하다. + +확인 화면은 등록 완료 후 push로 열고, 추가 등록은 새 입력 화면을 push한다. 재등록을 마치면 기존 확인 경로의 기간도 갱신한다. Mock 이용권은 마이페이지에 저장되지 않으므로 시안의 저장 안내 문구를 그대로 쓰지 않았다. 2026-10-19 iPhone 18 Pro 시뮬레이터에서 `Picke-TicketAudit` 코디네이터·화면 캡처 테스트 51개, 재등록 경로 테스트 11개, `Class` 모듈 이용권 테스트 8개가 각각 실패 0건으로 통과했다. + +확인 화면의 시각 판정 기록 (`13244:49243`, Figma 402pt 정규화 대 앱 윈도우 402pt 캡처): + +```json +{ + "score": 91, + "verdict": "pass", + "category_match": true, + "differences": [ + "앱 윈도우 캡처의 상단 콘텐츠가 Figma보다 약 10~13pt 아래에 있다.", + "Mock 안내 문구와 클래스 탭 선택은 현재 진입 및 저장 동작을 반영한다.", + "XCTest 앱 윈도우 캡처에는 시스템 상태 바가 포함되지 않는다." + ], + "suggestions": ["실제 앱 진입 캡처에서 상단 안전 영역을 재확인한다."], + "reasoning": "제목 크기, 카드 구성, 행 간격과 하단 버튼이 확정 시안과 가깝다. 안전 영역 차이는 테스트 호스트의 캡처 경계인지 실제 레이아웃 차이인지 아직 확정되지 않았다." +} +``` + +상단 10~13pt 오차는 테스트 호스트와 실제 앱의 안전 영역 차이일 수 있어 이번 판정에서 조건부 수용했다. 실제 앱 캡처에서 같은 오차가 재현되면 수정한다. + +다음 수정 우선순위는 실제 앱 진입 화면의 상단 안전 영역 확인, 댓글·답글 UI 캡처, 마감일 수정 시트의 최신 기기 캡처다. 완료 판정은 각각의 캡처와 Figma 확정 시안을 같은 크기로 대조한 뒤 내린다.