diff --git a/AppleStoreProductKiosk.xcodeproj/project.pbxproj b/AppleStoreProductKiosk.xcodeproj/project.pbxproj index aaaba52..5df1c83 100644 --- a/AppleStoreProductKiosk.xcodeproj/project.pbxproj +++ b/AppleStoreProductKiosk.xcodeproj/project.pbxproj @@ -38,7 +38,7 @@ 7F41C5542E798756007C4017 /* Exceptions for "AppleStoreProductKiosk" folder in "AppleStoreProductKiosk" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Resources/Info.plist, + App/Resources/Info.plist, ); target = C8F725DD2E790F9F00C2A1DE /* AppleStoreProductKiosk */; }; @@ -428,7 +428,7 @@ DEVELOPMENT_TEAM = N94CS4N6VR; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = AppleStoreProductKiosk/Resources/Info.plist; + INFOPLIST_FILE = AppleStoreProductKiosk/App/Resources/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -462,7 +462,7 @@ DEVELOPMENT_TEAM = N94CS4N6VR; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = AppleStoreProductKiosk/Resources/Info.plist; + INFOPLIST_FILE = AppleStoreProductKiosk/App/Resources/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme b/AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme new file mode 100644 index 0000000..ae51c67 --- /dev/null +++ b/AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AppleStoreProductKiosk/App/Application/AppDelegate.swift b/AppleStoreProductKiosk/App/Application/AppDelegate.swift new file mode 100644 index 0000000..0706632 --- /dev/null +++ b/AppleStoreProductKiosk/App/Application/AppDelegate.swift @@ -0,0 +1,27 @@ +// +// AppDelegate.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation +import UIKit + +import DiContainer + +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + UnifiedDI.enablePerformanceOptimization() + + DependencyContainer.bootstrapInTask { _ in + await AppDIContainer.shared.registerDefaultDependencies() + } + + return true + } +} diff --git a/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift new file mode 100644 index 0000000..7a69a95 --- /dev/null +++ b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift @@ -0,0 +1,26 @@ +// +// AppleStoreProductKioskApp.swift +// AppleStoreProductKiosk +// +// Created by 홍석현 on 9/16/25. +// + +import SwiftUI +import ComposableArchitecture + +@main +struct AppleStoreProductKioskApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + let store = Store(initialState: AppReducer.State()) { + AppReducer() + ._printChanges() + ._printChanges(.actionLabels) + } + + AppView(store: store) + } + } +} diff --git a/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift new file mode 100644 index 0000000..4e5d6d1 --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift @@ -0,0 +1,22 @@ +// +// Extension+AppDIContainer.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation +import DiContainer + +extension AppDIContainer { + func registerDefaultDependencies() async { + await registerDependencies { container in + // Repository 먼저 등록 + let factory = ModuleFactoryManager() + + await factory.registerAll(to: container) + + } + } +} + diff --git a/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift new file mode 100644 index 0000000..b9d1453 --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift @@ -0,0 +1,22 @@ +// +// Extension+RepositoryModuleFactory.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +import DiContainer + +extension RepositoryModuleFactory { + public mutating func registerDefaultDefinitions() { + let register = registerModule + + definitions = { + return [ + register.productRepositoryImplModule + ] + }() + } +} diff --git a/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift b/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift new file mode 100644 index 0000000..d288588 --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift @@ -0,0 +1,22 @@ +// +// Extension+UseCaseModuleFactory.swift.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +import DiContainer + +extension UseCaseModuleFactory { + public mutating func registerDefaultDefinitions() { + let register = registerModule + + definitions = { + return [ + register.productUseCaseImplModule, + ] + }() + } +} diff --git a/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift new file mode 100644 index 0000000..f5d1990 --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift @@ -0,0 +1,19 @@ +// +// ModuleFactoryManager.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +import DiContainer + +extension ModuleFactoryManager { + mutating func registerDefaultDependencies() { + // Repository + repositoryFactory.registerDefaultDefinitions() + + useCaseFactory.registerDefaultDefinitions() + } + } diff --git a/AppleStoreProductKiosk/App/Reducer/AppReducer.swift b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift new file mode 100644 index 0000000..26b23e7 --- /dev/null +++ b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift @@ -0,0 +1,54 @@ +// +// AppReducer.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +struct AppReducer { + + @ObservableState + enum State: Equatable { + case productList(ProductListFeature.State) + + init() { + self = .productList(.init()) + } + } + + enum Action: ViewAction { + case view(View) + } + + @CasePathable + enum View { + case productList(ProductListFeature.Action) + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .view(let ViewAction): + handleViewAction(&state, action: ViewAction) + } + } + .ifCaseLet(\.productList, action: \.view.productList) { + ProductListFeature() + } + } + + func handleViewAction( + _ state: inout State, + action: View + ) -> Effect { + switch action { + case .productList: + return .none + } + } +} diff --git a/AppleStoreProductKiosk/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/AppleStoreProductKiosk/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from AppleStoreProductKiosk/Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to AppleStoreProductKiosk/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/AppleStoreProductKiosk/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/AppleStoreProductKiosk/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from AppleStoreProductKiosk/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json rename to AppleStoreProductKiosk/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/AppleStoreProductKiosk/Resources/Assets.xcassets/Contents.json b/AppleStoreProductKiosk/App/Resources/Assets.xcassets/Contents.json similarity index 100% rename from AppleStoreProductKiosk/Resources/Assets.xcassets/Contents.json rename to AppleStoreProductKiosk/App/Resources/Assets.xcassets/Contents.json diff --git a/AppleStoreProductKiosk/Resources/Info.plist b/AppleStoreProductKiosk/App/Resources/Info.plist similarity index 100% rename from AppleStoreProductKiosk/Resources/Info.plist rename to AppleStoreProductKiosk/App/Resources/Info.plist diff --git a/AppleStoreProductKiosk/App/View/AppView.swift b/AppleStoreProductKiosk/App/View/AppView.swift new file mode 100644 index 0000000..f1f7fc6 --- /dev/null +++ b/AppleStoreProductKiosk/App/View/AppView.swift @@ -0,0 +1,23 @@ +// +// AppView.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import SwiftUI + +import ComposableArchitecture + +struct AppView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + switch store.state { + case .productList: + if let store = store.scope(state: \.productList, action: \.view.productList) { + ContentView(store: store) + } + } + } +} diff --git a/AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift b/AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift deleted file mode 100644 index 815782d..0000000 --- a/AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// AppleStoreProductKioskApp.swift -// AppleStoreProductKiosk -// -// Created by 홍석현 on 9/16/25. -// - -import SwiftUI - -@main -struct AppleStoreProductKioskApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift new file mode 100644 index 0000000..f7da533 --- /dev/null +++ b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift @@ -0,0 +1,56 @@ +// +// AppleStoreResponseDTO.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +struct AppleStoreResponseDTO: Decodable { + let code: Int + let message: String + let data: StoreDataDTO +} + +struct StoreDataDTO: Decodable { + let store: StoreInfoDTO + let categories: [CategoryDTO] +} + +struct StoreInfoDTO: Decodable { + let id: String + let region: String + let currency: String + let updatedAt: String +} + +struct CategoryDTO: Decodable { + let category: String + let products: [ProductDTO] +} + +struct ProductDTO: Decodable { + let id: String + let name: String? + let description: String? + let fromPrice: PriceDTO + let links: ProductLinksDTO? + let images: ProductImagesDTO +} + +struct PriceDTO: Decodable { + let amount: Int + let currency: String + let formatted: String +} + +struct ProductLinksDTO: Decodable { + let buy: String? + let imagePage: String? +} + +struct ProductImagesDTO: Decodable { + let main: String + let thumbnails: [String] +} diff --git a/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift b/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift new file mode 100644 index 0000000..782e12d --- /dev/null +++ b/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift @@ -0,0 +1,34 @@ +// +// Extension+StoreDataDTO.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +extension StoreDataDTO { + func toDomain() -> ProductCatalog { + let categories = categories.map { categoryDTO in + let products = categoryDTO.products.map { productDTO in + let productName = productDTO.name ?? productDTO.id.replacingOccurrences(of: "-", with: " ").capitalized + let productDescription = productDTO.description ?? "제품 설명이 없습니다." + + return Product( + id: productDTO.id, + name: productName, + description: productDescription, + priceFormatted: productDTO.fromPrice.formatted, + mainImageURL: URL(string: productDTO.images.main) + ) + } + + return Category( + name: categoryDTO.category, + products: products + ) + } + + return ProductCatalog(categories: categories) + } +} diff --git a/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift new file mode 100644 index 0000000..9f7d1ce --- /dev/null +++ b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift @@ -0,0 +1,132 @@ +// +// MockProductRepository.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation + +class MockProductRepository: ProductInterface { + private let categoriesData: [Category] + + init(categories: [Category]? = nil) { + if let categories { + self.categoriesData = categories + } else { + self.categoriesData = MockProductRepository.defaultCategories + } + } + + func fetchProductCatalog() async throws -> ProductCatalog { + .init(categories: categoriesData) + } + + func fetchProducts(for category: String) async throws -> [Product] { + let lowercased = category.lowercased() + return categoriesData.first { $0.name.lowercased() == lowercased }?.products ?? [] + } +} + +// MARK: - Default Mock Data +extension MockProductRepository { + static var defaultCategories: [Category] { + [ + Category( + name: "iPhone", + products: [ + Product( + id: "iphone-15-pro", + name: "iPhone 15 Pro", + description: "Titanium. A17 Pro chip. Pro camera system.", + priceFormatted: "$999", + mainImageURL: URL(string: "https://example.com/images/iphone15pro.jpg") + ), + Product( + id: "iphone-15", + name: "iPhone 15", + description: "Dynamic Island. 48MP Main camera.", + priceFormatted: "$799", + mainImageURL: URL(string: "https://example.com/images/iphone15.jpg") + ) + ] + ), + Category( + name: "Mac", + products: [ + Product( + id: "macbook-air-m2", + name: "MacBook Air", + description: "Supercharged by M2.", + priceFormatted: "$1199", + mainImageURL: URL(string: "https://example.com/images/macbookairm2.jpg") + ), + Product( + id: "macbook-pro-m3", + name: "MacBook Pro", + description: "M3 power. Stunning Liquid Retina XDR display.", + priceFormatted: "$1999", + mainImageURL: URL(string: "https://example.com/images/macbookprom3.jpg") + ) + ] + ), + Category( + name: "iPad", + products: [ + Product( + id: "ipad-pro-m2", + name: "iPad Pro", + description: "M2 chip. ProMotion. Thunderbolt.", + priceFormatted: "$1099", + mainImageURL: URL(string: "https://example.com/images/ipadpro.jpg") + ), + Product( + id: "ipad-mini", + name: "iPad mini", + description: "Compact. Powerful. Pocketable.", + priceFormatted: "$499", + mainImageURL: URL(string: "https://example.com/images/ipadmini.jpg") + ) + ] + ), + Category( + name: "Watch", + products: [ + Product( + id: "apple-watch-series-9", + name: "Apple Watch Series 9", + description: "Powerful health features. Double Tap gesture.", + priceFormatted: "$399", + mainImageURL: URL(string: "https://example.com/images/watchs9.jpg") + ), + Product( + id: "apple-watch-ultra-2", + name: "Apple Watch Ultra 2", + description: "Adventure ready. Brightest display yet.", + priceFormatted: "$799", + mainImageURL: URL(string: "https://example.com/images/watchultra2.jpg") + ) + ] + ), + Category( + name: "Accessories", + products: [ + Product( + id: "airpods-pro-2", + name: "AirPods Pro (2nd generation)", + description: "H2 chip. Adaptive Audio.", + priceFormatted: "$249", + mainImageURL: URL(string: "https://example.com/images/airpodspro2.jpg") + ), + Product( + id: "magic-keyboard", + name: "Magic Keyboard", + description: "Comfortable typing. Touch ID option.", + priceFormatted: "$99", + mainImageURL: URL(string: "https://example.com/images/magickeyboard.jpg") + ) + ] + ) + ] + } +} diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift b/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift deleted file mode 100644 index 260d607..0000000 --- a/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// AppleStoreProductKiosk -// -// Created by 홍석현 on 9/16/25. -// - diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift new file mode 100644 index 0000000..ce130bc --- /dev/null +++ b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift @@ -0,0 +1,35 @@ +// +// ProductRepositoryImpl.swift +// AppleStoreProductKiosk +// +// Created by 홍석현 on 9/16/25. +// + +import Combine +import DiContainer + + +class ProductRepositoryImpl: ProductInterface , ObservableObject { + private let provider = AsyncProvider(session: .shared) + + func fetchProductCatalog() async throws -> ProductCatalog { + let response = try await provider.requestAsync(.getAllProducts, decodeTo: AppleStoreResponseDTO.self) + return response.data.toDomain() + } + + func fetchProducts(for category: String) async throws -> [Product] { + let catalog = try await fetchProductCatalog() + return catalog.categories + .first { $0.name.lowercased() == category.lowercased() }? + .products ?? [] + } +} + + +extension RegisterModule { + var productRepositoryImplModule: () -> Module { + makeDependencyImproved(ProductInterface.self) { + ProductRepositoryImpl() + } + } +} diff --git a/AppleStoreProductKiosk/Domain/Entities/Product.swift b/AppleStoreProductKiosk/Domain/Entities/Product.swift index 260d607..8294fce 100644 --- a/AppleStoreProductKiosk/Domain/Entities/Product.swift +++ b/AppleStoreProductKiosk/Domain/Entities/Product.swift @@ -5,3 +5,23 @@ // Created by 홍석현 on 9/16/25. // +import Foundation + +struct ProductCatalog: Equatable { + let categories: [Category] + +} + +struct Category: Equatable { + public let name: String + public let products: [Product] +} + + +struct Product: Equatable { + public let id: String + public let name: String + public let description: String + public let priceFormatted: String + public let mainImageURL: URL? +} diff --git a/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift b/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift new file mode 100644 index 0000000..2800b94 --- /dev/null +++ b/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift @@ -0,0 +1,13 @@ +// +// ProductInterface.swift +// AppleStoreProductKiosk +// +// Created by 홍석현 on 9/16/25. +// + +import Foundation + +protocol ProductInterface { + func fetchProductCatalog() async throws -> ProductCatalog + func fetchProducts(for category: String) async throws -> [Product] +} diff --git a/AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift b/AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift deleted file mode 100644 index 260d607..0000000 --- a/AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// AppleStoreProductKiosk -// -// Created by 홍석현 on 9/16/25. -// - diff --git a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift deleted file mode 100644 index 260d607..0000000 --- a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// AppleStoreProductKiosk -// -// Created by 홍석현 on 9/16/25. -// - diff --git a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift new file mode 100644 index 0000000..304518d --- /dev/null +++ b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift @@ -0,0 +1,65 @@ +// +// ProductUseCaseImpl.swift +// AppleStoreProductKiosk +// +// Created by 홍석현 on 9/16/25. +// + +import Foundation + +import ComposableArchitecture +import DiContainer + +struct ProductUseCaseImpl: ProductInterface { + private let repository: ProductInterface + + init(repository: ProductInterface) { + self.repository = repository + } + + // MARK: - 카테고리들 전체 가지고오기 + func fetchProductCatalog() async throws -> ProductCatalog { + return try await repository.fetchProductCatalog() + } + + // MARK: -특정 카테고리 가져오기 + func fetchProducts(for category: String) async throws -> [Product] { + return try await repository.fetchProducts(for: category) + } +} + +extension DependencyContainer { + var productInterface: ProductInterface? { + resolve(ProductInterface.self) + } +} + +extension ProductUseCaseImpl: DependencyKey { + static var liveValue: ProductInterface { + let repository = UnifiedDI.register(\.productInterface) { + ProductRepositoryImpl() + } + return ProductUseCaseImpl(repository: repository) + } + static var testValue: ProductInterface = MockProductRepository() +} + +extension DependencyValues { + var productUseCase: ProductInterface { + get { self[ProductUseCaseImpl.self] } + set { self[ProductUseCaseImpl.self] = newValue } + } +} + +extension RegisterModule { + var productUseCaseImplModule: () -> Module { + makeUseCaseWithRepository( + ProductInterface.self, + repositoryProtocol: ProductInterface.self, + repositoryFallback: MockProductRepository(), + factory: { repo in + ProductUseCaseImpl(repository: repo) + } + ) + } +} diff --git a/AppleStoreProductKiosk/Features/ContentView.swift b/AppleStoreProductKiosk/Features/ContentView.swift index bb3e5a3..36f7b68 100644 --- a/AppleStoreProductKiosk/Features/ContentView.swift +++ b/AppleStoreProductKiosk/Features/ContentView.swift @@ -6,8 +6,10 @@ // import SwiftUI +import ComposableArchitecture struct ContentView: View { + @Perception.Bindable var store: StoreOf var body: some View { VStack { Image(systemName: "globe") @@ -16,9 +18,12 @@ struct ContentView: View { Text("Hello, world!") } .padding() + .onAppear { + store.send(.view(.onAppear)) + } } } -#Preview { - ContentView() -} +//#Preview { +// ContentView() +//} diff --git a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift index d0e565c..28d7d1a 100644 --- a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift +++ b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift @@ -5,26 +5,166 @@ // Created by 홍석현 on 9/16/25. // +import Foundation import ComposableArchitecture +import LogMacro + @Reducer -public struct ProductListFeature { - + struct ProductListFeature { + public init() {} + @ObservableState - public struct State { - + struct State: Equatable { + + init() {} + var productCatalogModel : ProductCatalog? = nil + } + + @CasePathable + enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + } - - public enum Action { + + //MARK: - ViewAction + @CasePathable + enum View { case onTapAddProduct(id: String) + case onSelectCategory(String) + case onAppear + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + @CasePathable + enum AsyncAction: Equatable { + case fetchProductCatalog + case fetchProducts(String) + } - - public var body: some Reducer { + + //MARK: - 앱내에서 사용하는 액션 + @CasePathable + enum InnerAction: Equatable { + case fetchProductCatalogResponse(Result) + case fetchProductsResponse(category: String, Result<[Product], DataError>) + } + + + private struct ProductListCancel: Hashable {} + + @Dependency(\.productUseCase) var productUseCase + @Dependency(\.mainQueue) var mainQueue + + var body: some ReducerOf { Reduce { state, action in switch action { - case .onTapAddProduct(let _): - return .none + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + } } } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onTapAddProduct(let id): + return .none + + case .onAppear: + return .run { send in + await send(.async(.fetchProductCatalog)) + } + .debounce(id: ProductListCancel(), for: 0.3, scheduler: mainQueue) + + case .onSelectCategory(let category): + return .run { send in + await send(.async(.fetchProducts(category))) + } + + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchProductCatalog: + return .run { send in + let productCatalogResult = await Result { + try await productUseCase.fetchProductCatalog() + } + + switch productCatalogResult { + case .success(let productCatalogData): + await send(.inner(.fetchProductCatalogResponse(.success(productCatalogData)))) + + case .failure(let error): + await send(.inner(.fetchProductCatalogResponse(.failure(.decodingError(error))))) + } + + } + + case .fetchProducts(let category): + return .run { send in + let result = await Result { + try await productUseCase.fetchProducts(for: category) + } + + switch result { + case .success(let products): + await send(.inner(.fetchProductsResponse(category: category, .success(products)))) + case .failure(let error): + await send(.inner(.fetchProductsResponse(category: category, .failure(.decodingError(error))))) + } + } + + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .fetchProductCatalogResponse(let result): + switch result { + case .success(let data): + state.productCatalogModel = data + + #logDebug("데이터", data) + + case .failure(let error): + #logNetwork("데이터 통신 실패", error.localizedDescription) + } + return .none + + case .fetchProductsResponse(let category, let result): + switch result { + case .success(let products): + state.productCatalogModel = ProductCatalog(categories: [ + Category(name: category, products: products) + ]) + #logDebug("상품 로드 완료: \(category)", products) + case .failure(let error): + #logNetwork("상품 로드 실패: \(category)", error.localizedDescription) + } + return .none + + } + } } diff --git a/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift b/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift index 37f8ea5..ba93978 100644 --- a/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift +++ b/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift @@ -41,3 +41,17 @@ enum DataError: Error, LocalizedError, Sendable { } } } + + +extension DataError: Equatable { + static func == (lhs: DataError, rhs: DataError) -> Bool { + switch (lhs, rhs) { + case (.noData, .noData): + return true + case let (.customError(a), .customError(b)): + return a == b + default: + return false + } + } +} diff --git a/AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan b/AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan new file mode 100644 index 0000000..ddd1c9b --- /dev/null +++ b/AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "EBCD31E0-26B2-40F7-A045-0A70416E5C44", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:AppleStoreProductKiosk.xcodeproj", + "identifier" : "C8F725EA2E790FA100C2A1DE", + "name" : "AppleStoreProductKioskTests" + } + } + ], + "version" : 1 +} diff --git a/AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift b/AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift deleted file mode 100644 index 4a85617..0000000 --- a/AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// AppleStoreProductKioskTests.swift -// AppleStoreProductKioskTests -// -// Created by 홍석현 on 9/16/25. -// - -import Testing -@testable import AppleStoreProductKiosk - -struct AppleStoreProductKioskTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift b/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift new file mode 100644 index 0000000..1bf5418 --- /dev/null +++ b/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift @@ -0,0 +1,36 @@ +// +// MockProductRepositoryTests.swift +// AppleStoreProductKioskTests +// +// Created by Wonji Suh on 9/17/25. +// + +import Testing +@testable import AppleStoreProductKiosk + +@MainActor +struct MockProductRepositoryTests { + private let repository = MockProductRepository() + + @Test("카탈로그는 기본 카테고리를 포함한다", .tags(.catalog)) + func fetchProductCatalog_hasDefaultCategories() async throws { + let catalog = try await repository.fetchProductCatalog() + #expect(!catalog.categories.isEmpty) + #expect(catalog.categories.count >= 3) + #expect(catalog.categories.contains { $0.name == "iPhone" }) + } + + @Test("특정 카테고리의 상품을 반환한다", .tags(.products)) + func fetchProducts_returnsProductsForCategory() async throws { + let products = try await repository.fetchProducts(for: "iPhone") + #expect(!products.isEmpty) + #expect(products.contains { $0.name.contains("iPhone") }) + } + + @Test("카테고리 매칭은 대소문자를 구분하지 않는다", .tags(.caseInsensitive)) + func fetchProducts_isCaseInsensitive() async throws { + let lower = try await repository.fetchProducts(for: "iphone") + let upper = try await repository.fetchProducts(for: "IPHONE") + #expect(lower.count == upper.count) + } +} diff --git a/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift new file mode 100644 index 0000000..560c26b --- /dev/null +++ b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift @@ -0,0 +1,32 @@ +// +// ProductUseCaseImplTests.swift +// AppleStoreProductKioskTests +// +// Created by Wonji Suh on 9/17/25. +// + +import Testing +@testable import AppleStoreProductKiosk + +@MainActor +struct ProductUseCaseImplTests { + @Test("유스케이스 카탈로그는 레포지토리와 동일하다", .tags(.catalog)) + func useCase_fetchCatalog_matchesMockRepository() async throws { + let repo = MockProductRepository() + let useCase = ProductUseCaseImpl(repository: repo) + + let expected = try await repo.fetchProductCatalog() + let actual = try await useCase.fetchProductCatalog() + #expect(actual == expected) + } + + + @Test("유스케이스로 카테고리 상품을 가져온다", .tags(.products)) + func useCase_fetchProducts_returnsCorrectCount() async throws { + let repo = MockProductRepository() + let useCase = ProductUseCaseImpl(repository: repo) + + let products = try await useCase.fetchProducts(for: "Accessories") + #expect(products.count == 2) + } +} diff --git a/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift new file mode 100644 index 0000000..d42e4ba --- /dev/null +++ b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift @@ -0,0 +1,88 @@ +// +// ProductListFeatureTest.swift +// AppleStoreProductKioskTests +// +// Created by Wonji Suh on 9/17/25. +// + +import Testing +import ComposableArchitecture +@testable import AppleStoreProductKiosk + +@Suite("ProductListFeatureTest", .tags(.feature, .productList)) +@MainActor +struct ProductListFeatureTest { + + @Test("onAppear 시 카탈로그 로드 및 상태 업데이트", .tags(.load)) + func loadCatalog_onAppear_updatesState() async throws { + let expectedCatalog = try await MockProductRepository().fetchProductCatalog() + + let store = TestStore(initialState: ProductListFeature.State()) { + ProductListFeature() + } withDependencies: { + $0.productUseCase = ProductUseCaseImpl.testValue + $0.mainQueue = .immediate + } + + await store.send(.view(.onAppear)) + await store.receive(\.async.fetchProductCatalog) + await store.receive(\.inner.fetchProductCatalogResponse) { + $0.productCatalogModel = expectedCatalog + } + } + + @Test("카탈로그 로드 실패 시 상태는 변경되지 않는다", .tags(.error)) + func loadCatalog_failure_keepsStateNil() async throws { + struct ThrowingUseCase: ProductInterface { + func fetchProductCatalog() async throws -> ProductCatalog { throw DataError.customError("boom") } + func fetchProducts(for category: String) async throws -> [Product] { [] } + } + + let store = TestStore(initialState: ProductListFeature.State()) { + ProductListFeature() + } withDependencies: { + $0.productUseCase = ThrowingUseCase() + $0.mainQueue = .immediate + } + + await store.send(.view(.onAppear)) + await store.receive(\.async.fetchProductCatalog) + await store.receive(\.inner.fetchProductCatalogResponse) + + #expect(store.state.productCatalogModel == nil) + } + + @Test("상품 추가 탭은 현재 상태를 변경하지 않는다", .tags(.action)) + func onTapAddProduct_noStateChange() async throws { + let initial = ProductListFeature.State() + let store = TestStore(initialState: initial) { + ProductListFeature() + } withDependencies: { + $0.productUseCase = ProductUseCaseImpl.testValue + } + + await store.send(.view(.onTapAddProduct(id: "some-id"))) + #expect(store.state == initial) + } + + @Test("카테고리 선택 시 상품 목록 로드", .tags(.load)) + func selectCategory_loadsProducts() async throws { + let repo = MockProductRepository() + let expected = try await repo.fetchProducts(for: "iPhone") + + let store = TestStore(initialState: ProductListFeature.State()) { + ProductListFeature() + } withDependencies: { + $0.productUseCase = ProductUseCaseImpl.testValue + } + + let category = "iPhone" + await store.send(.view(.onSelectCategory(category))) + await store.receive(\.async.fetchProducts) + await store.receive(\.inner.fetchProductsResponse) { state in + state.productCatalogModel = ProductCatalog(categories: [ + Category(name: category, products: expected) + ]) + } + } +} diff --git a/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift new file mode 100644 index 0000000..55adb37 --- /dev/null +++ b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift @@ -0,0 +1,31 @@ +// +// KioskProductServiceLiveTests.swift +// AppleStoreProductKioskTests +// +// Created by Wonji Suh on 9/17/25. +// + +import Testing +@testable import AppleStoreProductKiosk + +@Suite("실 API 테스트", .tags(.liveAPI, .network, .integration)) +struct KioskProductServiceLiveTests { + + @Test("실 API: /products 응답 디코딩 성공") + func live_fetch_products_decodes() async throws { + let provider = AsyncProvider.conservative() + let response = try await provider.requestAsync(.getAllProducts, decodeTo: AppleStoreResponseDTO.self) + + #expect(!response.data.categories.isEmpty) + } + + @Test("실 API: 레포지토리로 도메인 변환 성공") + func live_repository_fetchCatalog_returnsDomain() async throws { + let repo = ProductRepositoryImpl() + let catalog = try await repo.fetchProductCatalog() + + #expect(!catalog.categories.isEmpty) + // 적어도 하나의 카테고리에 하나 이상의 상품이 있어야 함 + #expect(catalog.categories.contains { !$0.products.isEmpty }) + } +} diff --git a/AppleStoreProductKioskTests/TestingTags.swift b/AppleStoreProductKioskTests/TestingTags.swift new file mode 100644 index 0000000..8aadc0d --- /dev/null +++ b/AppleStoreProductKioskTests/TestingTags.swift @@ -0,0 +1,27 @@ +// +// TestingTags.swift +// AppleStoreProductKioskTests +// +// Created by Wonji Suh on 9/17/25. +// + +import Testing + +extension Tag { + @Tag static var mock: Self + @Tag static var repository: Self + @Tag static var usecase: Self + @Tag static var feature: Self + @Tag static var productList: Self + @Tag static var load: Self + @Tag static var error: Self + @Tag static var action: Self + @Tag static var catalog: Self + @Tag static var products: Self + @Tag static var caseInsensitive: Self + @Tag static var example: Self + @Tag static var sanity: Self + @Tag static var liveAPI: Self + @Tag static var network: Self + @Tag static var integration: Self +}