From 729f3d615de7f779446f3c9691dba0ed3b616494 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Thu, 28 May 2026 22:49:32 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20CodeGraph=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20-=20#322?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ AGENTS.md | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.gitignore b/.gitignore index 6cf19827..cb424128 100644 --- a/.gitignore +++ b/.gitignore @@ -180,5 +180,8 @@ src/SupportingFiles/Booket/GoogleService-Info.plist # Performance traces and local probe workspace (large, generated) .perf/ +# CodeGraph local index (generated, per-working-tree SQLite data) +.codegraph/ + # Claude Code scheduled-task runtime lock (per-machine, not shared) .claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md index 14885ed0..c7f208fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,26 @@ If a referenced doc is missing, ask before assuming its contents. --- +## Repository intelligence + +When CodeGraph is available, use it for broad repository exploration, symbol +relationship discovery, and impact analysis before falling back to wide +`rg`/file-reading sweeps. + +Use this workflow: + +- For architecture or unfamiliar-area questions, start with CodeGraph + `context`, `query`, `impact`, `callers`, or `callees`. +- For exact literal searches, narrow symbol lookups, and final evidence, use + `rg` and direct file reads. +- If CodeGraph reports stale or pending files, verify those files directly + before relying on the indexed result. +- Do not commit `.codegraph/`; it is a generated local SQLite index. Regenerate + it with `codegraph init -i` when missing, and use `codegraph sync` only when + working outside an active MCP watcher or after a branch switch / batch edit. + +--- + ## Architecture guardrails ### Feature structure From 1b2590c4fa14c489344fd0a58b04479683ae19c7 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Fri, 29 May 2026 01:56:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=EC=9E=AC=ED=91=9C=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=20-=20#323?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modifiers/View+TxBottomSheet.swift | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift index 5d91cb73..33150755 100644 --- a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift @@ -50,6 +50,8 @@ private struct TXBottomSheetModifier: ViewModifier { @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height @State private var dimmedOpacity: CGFloat = 0 @State private var contentHeight: CGFloat = 0 + @State private var isDismissing = false + @State private var dismissalGeneration = 0 private let animationDuration: TimeInterval = 0.2 private let dragAreaHeight: CGFloat = 28 @@ -57,7 +59,13 @@ private struct TXBottomSheetModifier: ViewModifier { content .onChange(of: isPresented) { if isPresented { - isCoverPresented = true + dismissalGeneration += 1 + isDismissing = false + if isCoverPresented { + presentAnimated() + } else { + isCoverPresented = true + } } else { startDismiss() } @@ -90,6 +98,11 @@ private extension TXBottomSheetModifier { .padding(.bottom, TXSafeArea.inset(.bottom)) .frame(maxWidth: .infinity, alignment: .bottom) .background(Color.Common.white) + .overlay { + Color.clear + .accessibilityIdentifier("tx.bottom-sheet.content") + .allowsHitTesting(false) + } .clipShape( UnevenRoundedRectangle(cornerRadii: .init(topLeading: Radius.m, topTrailing: Radius.m)) ) @@ -121,6 +134,7 @@ private extension TXBottomSheetModifier { .padding(.vertical, 11) } } + .accessibilityIdentifier("tx.bottom-sheet.drag-area") .gesture( DragGesture(coordinateSpace: .global) .onChanged { value in @@ -147,6 +161,7 @@ private extension TXBottomSheetModifier { .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture { startDismiss() } + .accessibilityIdentifier("tx.bottom-sheet.backdrop") } } @@ -156,10 +171,17 @@ private extension TXBottomSheetModifier { sheetOffset = UIScreen.main.bounds.height dimmedOpacity = 0 isPresented = false + isDismissing = false + dismissalGeneration += 1 } func presentAnimated() { + let currentGeneration = dismissalGeneration + Task { @MainActor in + await Task.yield() + guard currentGeneration == dismissalGeneration, isPresented else { return } + withAnimation(.easeOut(duration: animationDuration)) { dimmedOpacity = 1 sheetOffset = 0 @@ -168,6 +190,11 @@ private extension TXBottomSheetModifier { } func startDismiss() { + guard !isDismissing else { return } + isDismissing = true + dismissalGeneration += 1 + let currentDismissalGeneration = dismissalGeneration + if isPresented { isPresented = false } @@ -179,12 +206,14 @@ private extension TXBottomSheetModifier { Task { @MainActor in try await Task.sleep(for: .seconds(animationDuration)) + guard currentDismissalGeneration == dismissalGeneration else { return } isCoverPresented = false + isDismissing = false } } func updateContentHeight(_ newHeight: CGFloat) { - guard newHeight > 0 else { return } + guard newHeight > 0, abs(contentHeight - newHeight) > 0.5 else { return } contentHeight = newHeight } } From 80cf7ab7a721d7e66c7a8a36feb76a70a8de7983 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Fri, 29 May 2026 01:56:12 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20-=20#323?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BottomSheet/TXCalendarBottomSheet.swift | 115 +++++++++++++++--- 1 file changed, 100 insertions(+), 15 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift index f53d5162..0975baee 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift @@ -36,6 +36,7 @@ public struct TXCalendarBottomSheet: View { @Binding private var selectedDate: TXCalendarDate @State private var isDatePickerMode = false @State private var frozenCalendarHeight: CGFloat? + @State private var calendarData: CalendarPresentationData private let buttonContent: (_ exitPickerModeIfNeeded: @escaping () -> Bool) -> ButtonContent private let completeButtonText: String? @@ -65,6 +66,7 @@ public struct TXCalendarBottomSheet: View { @ViewBuilder buttonContent: @escaping (_ exitPickerModeIfNeeded: @escaping () -> Bool) -> ButtonContent ) { self._selectedDate = selectedDate + self._calendarData = State(initialValue: Self.makeCalendarData(for: selectedDate.wrappedValue)) self.buttonContent = buttonContent self.completeButtonText = nil self.onComplete = nil @@ -72,9 +74,11 @@ public struct TXCalendarBottomSheet: View { } public var body: some View { - let currentWeeks = TXCalendarDataGenerator.generateMonthData(for: selectedDate) - let displayWeeks = applyDisabledStatus(to: currentWeeks) - let currentCalendarHeight = calendarContentHeight(for: currentWeeks) + let currentData = calendarData.matches(selectedDate) + ? calendarData + : Self.makeCalendarData(for: selectedDate) + let displayWeeks = applyDisabledStatus(to: currentData.weeks) + let currentCalendarHeight = currentData.height VStack(spacing: 0) { // MonthNavigation + Calendar @@ -87,8 +91,8 @@ public struct TXCalendarBottomSheet: View { } isDatePickerMode.toggle() }, - onPrevious: { selectedDate.goToPreviousMonth() }, - onNext: { selectedDate.goToNextMonth() } + onPrevious: { updateSelectedDate { $0.goToPreviousMonth() } }, + onNext: { updateSelectedDate { $0.goToNextMonth() } } ) if isDatePickerMode { @@ -101,7 +105,7 @@ public struct TXCalendarBottomSheet: View { config: calendarConfig ) { item in if let day = Int(item.text), item.status != .lastDate { - selectedDate.selectDay(day) + updateSelectedDate { $0.selectDay(day) } } } } @@ -113,11 +117,19 @@ public struct TXCalendarBottomSheet: View { } .frame(maxWidth: .infinity) .background(Color.Common.white) + .overlay { + Color.clear + .accessibilityIdentifier("tx.calendar-bottom-sheet") + .allowsHitTesting(false) + } .onChange(of: isDatePickerMode) { _, newValue in if !newValue { frozenCalendarHeight = nil } } + .onChange(of: selectedDate) { _, newValue in + updateCalendarData(for: newValue) + } } } @@ -140,6 +152,7 @@ public extension TXCalendarBottomSheet where ButtonContent == DefaultCalendarBut isDateEnabled: ((TXCalendarDateItem) -> Bool)? = nil ) { self._selectedDate = selectedDate + self._calendarData = State(initialValue: Self.makeCalendarData(for: selectedDate.wrappedValue)) self.buttonContent = { _ in DefaultCalendarButton(text: completeButtonText, action: onComplete) } @@ -164,28 +177,43 @@ public struct DefaultCalendarButton: View { onTap: action ) .padding(.horizontal, Spacing.spacing8) + .accessibilityIdentifier("tx.calendar-bottom-sheet.complete-button") } } // MARK: - Private Views private extension TXCalendarBottomSheet { - var calendarConfig: TXCalendar.Configuration { + static var calendarConfig: TXCalendar.Configuration { .init( monthlyHeaderSpacing: Spacing.spacing7, monthlyRowSpacing: Spacing.spacing6 ) } - func calendarContentHeight(for weeks: [[TXCalendarDateItem]]) -> CGFloat { - let headerHeight = TXCalendarLayout.weekdayLabelHeight(calendarConfig.weekdayTypography) - let headerSectionHeight = headerHeight + calendarConfig.monthlyHeaderSpacing - let verticalPadding = calendarConfig.verticalPadding * 2 + var calendarConfig: TXCalendar.Configuration { + Self.calendarConfig + } + + static func makeCalendarData(for date: TXCalendarDate) -> CalendarPresentationData { + let weeks = TXCalendarDataGenerator.generateMonthData(for: date) + return CalendarPresentationData( + key: .init(date), + weeks: weeks, + height: calendarContentHeight(for: weeks) + ) + } + + static func calendarContentHeight(for weeks: [[TXCalendarDateItem]]) -> CGFloat { + let config = calendarConfig + let headerHeight = TXCalendarLayout.weekdayLabelHeight(config.weekdayTypography) + let headerSectionHeight = headerHeight + config.monthlyHeaderSpacing + let verticalPadding = config.verticalPadding * 2 guard !weeks.isEmpty else { return headerSectionHeight + verticalPadding } let rowCount = CGFloat(weeks.count) - let rowSpacing = calendarConfig.monthlyRowSpacing * CGFloat(weeks.count - 1) - let monthGridHeight = (calendarConfig.dateStyle.size * rowCount) + rowSpacing + let rowSpacing = config.monthlyRowSpacing * CGFloat(weeks.count - 1) + let monthGridHeight = (config.dateStyle.size * rowCount) + rowSpacing return headerSectionHeight + monthGridHeight + verticalPadding } @@ -215,14 +243,14 @@ private extension TXCalendarBottomSheet { func datePickerView(height: CGFloat) -> some View { HStack(spacing: 0) { - Picker("Year", selection: $selectedDate.year) { + Picker("Year", selection: selectedYear) { ForEach(1940...2099, id: \.self) { year in Text(verbatim: "\(year)년").tag(year) } } .pickerStyle(.wheel) - Picker("Month", selection: $selectedDate.month) { + Picker("Month", selection: selectedMonth) { ForEach(1...12, id: \.self) { month in Text(verbatim: "\(month)월").tag(month) } @@ -233,6 +261,41 @@ private extension TXCalendarBottomSheet { .padding(.horizontal, Spacing.spacing7) } + + var selectedYear: Binding { + Binding( + get: { selectedDate.year }, + set: { year in + updateSelectedDate { date in + date.year = year + } + } + ) + } + + var selectedMonth: Binding { + Binding( + get: { selectedDate.month }, + set: { month in + updateSelectedDate { date in + date.month = month + } + } + ) + } + + func updateSelectedDate(_ update: (inout TXCalendarDate) -> Void) { + var newDate = selectedDate + update(&newDate) + selectedDate = newDate + updateCalendarData(for: newDate) + } + + func updateCalendarData(for date: TXCalendarDate) { + guard !calendarData.matches(date) else { return } + calendarData = Self.makeCalendarData(for: date) + } + func applyDisabledStatus(to weeks: [[TXCalendarDateItem]]) -> [[TXCalendarDateItem]] { guard let isDateEnabled else { return weeks } return weeks.map { week in @@ -249,3 +312,25 @@ private extension TXCalendarBottomSheet { } } } + +private struct CalendarPresentationData: Equatable { + struct Key: Equatable { + let year: Int + let month: Int + let day: Int? + + init(_ date: TXCalendarDate) { + self.year = date.year + self.month = date.month + self.day = date.day + } + } + + let key: Key + let weeks: [[TXCalendarDateItem]] + let height: CGFloat + + func matches(_ date: TXCalendarDate) -> Bool { + key == Key(date) + } +} From f29bc479dfd65f44099ce8b309d0d7d770af817f Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Fri, 29 May 2026 01:56:12 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EB=B0=94=ED=85=80=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=83=AD=EB=B0=94=20=ED=9A=8C=EA=B7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80=20-=20#323?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Example/Sources/MainTabExampleView.swift | 141 ++++++++++++++++-- .../Sources/MainTabExampleSmokeTests.swift | 128 ++++++++++++++++ Projects/Feature/MainTab/Project.swift | 3 +- .../Components/Bar/TabBar/TXTabBar.swift | 16 ++ .../TXCalendarMonthNavigation.swift | 5 + .../Sources/UITestMode.swift | 7 + 6 files changed, 286 insertions(+), 14 deletions(-) diff --git a/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift b/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift index 2c026e4f..b941a8b4 100644 --- a/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift +++ b/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift @@ -15,24 +15,29 @@ import CoreCaptureSessionInterface import DomainGoalInterface import FeatureMakeGoal import FeatureMakeGoalInterface +import SharedDesignSystem import SharedPerfTestingSupport struct MainTabExampleView: View { var body: some View { - MainTabView( - store: Store( - initialState: MainTabReducer.State(), - reducer: { - MainTabReducer() - }, withDependencies: { - $0.goalClient = .previewValue - $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue - $0.proofPhotoFactory = .liveValue - $0.goalDetailFactory = .liveValue - $0.makeGoalFactory = .liveValue - } + if ProcessInfo.processInfo.arguments.contains("-UITEST_DESIGN_SYSTEM_BOTTOM_SHEET_SCENARIO") { + DesignSystemBottomSheetScenarioView() + } else { + MainTabView( + store: Store( + initialState: MainTabReducer.State(), + reducer: { + MainTabReducer() + }, withDependencies: { + $0.goalClient = .previewValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.proofPhotoFactory = .liveValue + $0.goalDetailFactory = .liveValue + $0.makeGoalFactory = .liveValue + } + ) ) - ) + } } } @@ -50,3 +55,113 @@ private extension CaptureSessionClient { switchFlash: { _ in } ) } + +private struct DesignSystemBottomSheetScenarioView: View { + @State private var selectedTab: TXTabItem = .home + @State private var selectedDate = TXCalendarDate(year: 2026, month: 5, day: 28) + @State private var isBottomSheetPresented = false + @State private var completedCount = 0 + @State private var selfRunStep = 0 + @State private var hasStartedSelfRun = false + + var body: some View { + TXTabBarContainer(selectedItem: $selectedTab) { + scenarioContent(title: "홈") + .tag(TXTabItem.home) + + scenarioContent(title: "통계") + .tag(TXTabItem.statistics) + } + .txBottomSheet( + isPresented: $isBottomSheetPresented, + showDragIndicator: true + ) { + TXCalendarBottomSheet( + selectedDate: $selectedDate, + completeButtonText: "완료", + onComplete: { + completedCount += 1 + isBottomSheetPresented = false + } + ) + .overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier( + "example.bottom-sheet.calendar-month.\(selectedDate.formattedYearDashMonth)" + ) + } + } + .overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("example.bottom-sheet.completed-count.\(completedCount)") + } + .overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("example.bottom-sheet.self-run-step.\(selfRunStep)") + } + .task { + await runCalendarBottomSheetSelfRunIfNeeded() + } + } + + private func scenarioContent(title: String) -> some View { + VStack(spacing: Spacing.spacing6) { + Text(title) + .typography(.t1_18eb) + .foregroundStyle(Color.Gray.gray500) + + Button("캘린더 바텀시트 열기") { + isBottomSheetPresented = true + } + .accessibilityIdentifier("example.bottom-sheet.present-button") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.Common.white) + .overlay(alignment: .topLeading) { + Button { + triggerQuickRepresentRace() + } label: { + Color.clear + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("example.bottom-sheet.quick-represent-button") + } + } + + private func triggerQuickRepresentRace() { + isBottomSheetPresented = true + + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + isBottomSheetPresented = false + try? await Task.sleep(for: .milliseconds(50)) + isBottomSheetPresented = true + } + } + + @MainActor + private func runCalendarBottomSheetSelfRunIfNeeded() async { + guard UITestMode.isSwiftUISelfRunCalendarBottomSheet, !hasStartedSelfRun else { return } + hasStartedSelfRun = true + + try? await Task.sleep(for: .milliseconds(900)) + for iteration in 1...4 { + selfRunStep = (iteration * 10) + 1 + isBottomSheetPresented = true + try? await Task.sleep(for: .milliseconds(650)) + + selfRunStep = (iteration * 10) + 2 + selectedDate.goToNextMonth() + try? await Task.sleep(for: .milliseconds(250)) + + selfRunStep = (iteration * 10) + 3 + isBottomSheetPresented = false + try? await Task.sleep(for: .milliseconds(450)) + } + + selfRunStep = 999 + } +} diff --git a/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift b/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift index 88621a30..b70047d5 100644 --- a/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift +++ b/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift @@ -6,4 +6,132 @@ final class MainTabExampleSmokeTests: XCTestCase { _ = XCUIApplication.launchForPerf(seed: "default") waitForFeatureReady("main-tab") } + + func testCalendarBottomSheetCoversCustomTabBarAndCompletes() { + let app = launchDesignSystemBottomSheetScenario() + waitForFeatureReady("main-tab") + + let homeTab = app.buttons[DesignSystemBottomSheetScenarioID.homeTab] + let statisticsTab = app.buttons[DesignSystemBottomSheetScenarioID.statisticsTab] + XCTAssertTrue(homeTab.waitForExistence(timeout: 5)) + XCTAssertTrue(statisticsTab.waitForExistence(timeout: 5)) + let tabBarFrame = homeTab.frame.union(statisticsTab.frame) + attachScreenshot(named: "01-tabbar-baseline") + + app.buttons[DesignSystemBottomSheetScenarioID.presentButton].tap() + + let sheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(sheet.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarSheet].exists) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.dragArea].exists) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarMonth("2026-05")].exists) + XCTAssertLessThan(sheet.frame.minY, tabBarFrame.maxY) + XCTAssertGreaterThanOrEqual(sheet.frame.maxY, tabBarFrame.maxY - 1) + attachScreenshot(named: "02-sheet-over-tabbar") + + app.buttons[DesignSystemBottomSheetScenarioID.calendarNextButton].tap() + XCTAssertTrue( + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarMonth("2026-06")] + .waitForExistence(timeout: 2) + ) + attachScreenshot(named: "03-calendar-next-month") + + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completeButton].tap() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 3)) + XCTAssertTrue(homeTab.waitForExistence(timeout: 3)) + XCTAssertTrue(statisticsTab.waitForExistence(timeout: 3)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completedCount(1)].exists) + attachScreenshot(named: "04-dismissed-tabbar-restored") + } + + func testBottomSheetBackdropDismissesAndCanPresentRepeatedly() { + let app = launchDesignSystemBottomSheetScenario() + waitForFeatureReady("main-tab") + + let presentButton = app.buttons[DesignSystemBottomSheetScenarioID.presentButton] + XCTAssertTrue(presentButton.waitForExistence(timeout: 5)) + + presentButton.tap() + let firstSheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(firstSheet.waitForExistence(timeout: 5)) + attachScreenshot(named: "01-first-presentation") + + let backdrop = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.backdrop] + XCTAssertTrue(backdrop.waitForExistence(timeout: 2)) + backdrop.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap() + XCTAssertTrue(firstSheet.waitForNonExistence(timeout: 3)) + attachScreenshot(named: "02-backdrop-dismissed") + + presentButton.tap() + let secondSheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(secondSheet.waitForExistence(timeout: 5)) + attachScreenshot(named: "03-second-presentation") + + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completeButton].tap() + XCTAssertTrue(secondSheet.waitForNonExistence(timeout: 3)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completedCount(1)].exists) + attachScreenshot(named: "04-second-dismissed") + } + + func testBottomSheetQuickRepresentDuringDismissKeepsSheetVisible() { + let app = launchDesignSystemBottomSheetScenario() + waitForFeatureReady("main-tab") + + let quickRepresentButton = app.buttons[DesignSystemBottomSheetScenarioID.quickRepresentButton] + XCTAssertTrue(quickRepresentButton.waitForExistence(timeout: 5)) + + quickRepresentButton.tap() + let sheet = app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.sheetContent] + XCTAssertTrue(sheet.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.calendarSheet].exists) + attachScreenshot(named: "01-quick-represent-sheet-visible") + + app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completeButton].tap() + XCTAssertTrue(sheet.waitForNonExistence(timeout: 3)) + XCTAssertTrue(app.descendants(matching: .any)[DesignSystemBottomSheetScenarioID.completedCount(1)].exists) + attachScreenshot(named: "02-quick-represent-dismissed") + } + + private func launchDesignSystemBottomSheetScenario() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append(contentsOf: [ + "-UITEST", + "-UITEST_SEED", "default", + "-UITEST_WAIT_READY", + "-UITEST_DISABLE_ANIMATIONS", + "-UITEST_DESIGN_SYSTEM_BOTTOM_SHEET_SCENARIO" + ]) + app.launch() + return app + } + + private func attachScreenshot(named name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} + +private enum DesignSystemBottomSheetScenarioID { + static let presentButton = "example.bottom-sheet.present-button" + static let quickRepresentButton = "example.bottom-sheet.quick-represent-button" + static let completedCountPrefix = "example.bottom-sheet.completed-count" + static let calendarMonthPrefix = "example.bottom-sheet.calendar-month" + static let sheetContent = "tx.bottom-sheet.content" + static let backdrop = "tx.bottom-sheet.backdrop" + static let dragArea = "tx.bottom-sheet.drag-area" + static let calendarSheet = "tx.calendar-bottom-sheet" + static let completeButton = "tx.calendar-bottom-sheet.complete-button" + static let calendarNextButton = "tx.calendar.month-navigation.next-button" + static let homeTab = "tx.tab-bar.item.home" + static let statisticsTab = "tx.tab-bar.item.statistics" + + static func completedCount(_ count: Int) -> String { + "\(completedCountPrefix).\(count)" + } + + static func calendarMonth(_ yearDashMonth: String) -> String { + "\(calendarMonthPrefix).\(yearDashMonth)" + } } diff --git a/Projects/Feature/MainTab/Project.swift b/Projects/Feature/MainTab/Project.swift index d4158fd9..6102e681 100644 --- a/Projects/Feature/MainTab/Project.swift +++ b/Projects/Feature/MainTab/Project.swift @@ -37,7 +37,8 @@ let project = Project.makeModule( ), dependencies: [ .feature, - .core(implements: .captureSession) + .core(implements: .captureSession), + .shared(implements: .designSystem) ] ) ), diff --git a/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift b/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift index eab4e17b..cf35de20 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Bar/TabBar/TXTabBar.swift @@ -53,6 +53,7 @@ private extension TXTabBar { .padding(.top, Constants.topPadding) } .buttonStyle(.plain) + .accessibilityIdentifier("tx.tab-bar.item.\(item.accessibilityIdentifier)") } } @@ -70,3 +71,18 @@ private extension TXTabBar { static let labelFont: TypographyToken = .c2_11b } } + +private extension TXTabItem { + var accessibilityIdentifier: String { + switch self { + case .home: + return "home" + + case .statistics: + return "statistics" + + case .couple: + return "couple" + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift index d3f8528e..3990ba92 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Navigation/TXCalendarMonthNavigation.swift @@ -81,12 +81,14 @@ public struct TXCalendarMonthNavigation: View { HStack(spacing: config.itemSpacing) { navigationButton( icon: .Icon.Symbol.arrow1MLeft, + accessibilityIdentifier: "tx.calendar.month-navigation.previous-button", isDisabled: isPreviousDisabled, action: onPrevious ) titleView navigationButton( icon: .Icon.Symbol.arrow1MRight, + accessibilityIdentifier: "tx.calendar.month-navigation.next-button", isDisabled: isNextDisabled, action: onNext ) @@ -107,6 +109,7 @@ private extension TXCalendarMonthNavigation { titleLabel } .buttonStyle(.plain) + .accessibilityIdentifier("tx.calendar.month-navigation.title-button") } else { titleLabel } @@ -123,6 +126,7 @@ private extension TXCalendarMonthNavigation { private extension TXCalendarMonthNavigation { func navigationButton( icon: Image, + accessibilityIdentifier: String, isDisabled: Bool, action: @escaping () -> Void ) -> some View { @@ -136,5 +140,6 @@ private extension TXCalendarMonthNavigation { .buttonStyle(.plain) .disabled(isDisabled) .frame(width: config.buttonSize, height: config.buttonSize) + .accessibilityIdentifier(accessibilityIdentifier) } } diff --git a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift index 7d2ec7a7..f8b178ee 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift @@ -57,6 +57,13 @@ public enum UITestMode { arguments.contains("-UITEST_SWIFTUI_SELF_RUN_STATS_SCROLL") } + /// MainTab Calendar bottomsheet self-running presentation을 켤지 나타냅니다. + /// xctrace launch-mode가 Calendar bottomsheet presentation window를 직접 캡처하도록 돕는 + /// Example/perf 전용 flag입니다. + public static var isSwiftUISelfRunCalendarBottomSheet: Bool { + arguments.contains("-UITEST_SWIFTUI_SELF_RUN_CALENDAR_BOTTOM_SHEET") + } + /// 현재 launch argument에 따라 앱 전역 UITest 설정을 적용합니다. /// 지금은 `-UITEST_DISABLE_ANIMATIONS`가 있을 때 UIKit animation을 비활성화합니다. ///