From 87ec65bf5c21963377ebb5ab5ecb65dda6aaf502 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:23:28 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=AA=9B[chore]:=20=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Application/AppDelegate.swift | 8 ++++++ .../AppleStoreProductKioskApp.swift | 17 +++++++++++ .../App/Di/Extension+AppDIContainer.swift | 8 ++++++ .../Extension+RepositoryModuleFactory.swift | 8 ++++++ ...Extension+UseCaseModuleFactory.swift.swift | 0 .../App/Di/ModuleFactoryManager.swift | 8 ++++++ .../App/Reducer/AppReducer.swift | 8 ++++++ .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../Resources/Assets.xcassets/Contents.json | 0 .../{ => App}/Resources/Info.plist | 0 AppleStoreProductKiosk/App/View/AppView.swift | 8 ++++++ .../Model/Product/AppleStoreResponseDTO.swift | 8 ++++++ .../Mapper+/Extension+StoreDataDTO.swift | 8 ++++++ .../Repositories/MockProductRepository.swift | 8 ++++++ .../Repositories/ProductRepositoryImpl.swift | 26 +++++++++++++++++ .../Interface/ProductInterface.swift} | 0 .../Interface/ProductRepositoryProtocol.swift | 7 ----- .../Domain/UseCase/ProductUseCase.swift | 7 ----- .../Domain/UseCase/ProductUseCaseImpl.swift | 28 +++++++++++++++++++ 20 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 AppleStoreProductKiosk/App/Application/AppDelegate.swift create mode 100644 AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift create mode 100644 AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift create mode 100644 AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift create mode 100644 AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift create mode 100644 AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift create mode 100644 AppleStoreProductKiosk/App/Reducer/AppReducer.swift rename AppleStoreProductKiosk/{ => App}/Resources/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename AppleStoreProductKiosk/{ => App}/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename AppleStoreProductKiosk/{ => App}/Resources/Assets.xcassets/Contents.json (100%) rename AppleStoreProductKiosk/{ => App}/Resources/Info.plist (100%) create mode 100644 AppleStoreProductKiosk/App/View/AppView.swift create mode 100644 AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift create mode 100644 AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift create mode 100644 AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift create mode 100644 AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift rename AppleStoreProductKiosk/{Data/Repositories/ProductRepository.swift => Domain/Interface/ProductInterface.swift} (100%) delete mode 100644 AppleStoreProductKiosk/Domain/Interface/ProductRepositoryProtocol.swift delete mode 100644 AppleStoreProductKiosk/Domain/UseCase/ProductUseCase.swift create mode 100644 AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift diff --git a/AppleStoreProductKiosk/App/Application/AppDelegate.swift b/AppleStoreProductKiosk/App/Application/AppDelegate.swift new file mode 100644 index 0000000..704ad96 --- /dev/null +++ b/AppleStoreProductKiosk/App/Application/AppDelegate.swift @@ -0,0 +1,8 @@ +// +// AppDelegate.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift new file mode 100644 index 0000000..815782d --- /dev/null +++ b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift @@ -0,0 +1,17 @@ +// +// 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/App/Di/Extension+AppDIContainer.swift b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift new file mode 100644 index 0000000..6c7bff4 --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift @@ -0,0 +1,8 @@ +// +// Extension+AppDIContainer.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift new file mode 100644 index 0000000..6fc72fe --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift @@ -0,0 +1,8 @@ +// +// Extension+RepositoryModuleFactory.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift b/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift new file mode 100644 index 0000000..e69de29 diff --git a/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift new file mode 100644 index 0000000..e8167cd --- /dev/null +++ b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift @@ -0,0 +1,8 @@ +// +// ModuleFactoryManager.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/App/Reducer/AppReducer.swift b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift new file mode 100644 index 0000000..ebe85e6 --- /dev/null +++ b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift @@ -0,0 +1,8 @@ +// +// AppReducer.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation 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..fd5d144 --- /dev/null +++ b/AppleStoreProductKiosk/App/View/AppView.swift @@ -0,0 +1,8 @@ +// +// AppView.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift new file mode 100644 index 0000000..70c6c72 --- /dev/null +++ b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift @@ -0,0 +1,8 @@ +// +// AppleStoreResponseDTO.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation 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..c6126ff --- /dev/null +++ b/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift @@ -0,0 +1,8 @@ +// +// Extension+StoreDataDTO.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift new file mode 100644 index 0000000..27c9302 --- /dev/null +++ b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift @@ -0,0 +1,8 @@ +// +// MockProductRepository.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/17/25. +// + +import Foundation diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift new file mode 100644 index 0000000..7ac07a0 --- /dev/null +++ b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift @@ -0,0 +1,26 @@ +// +// ProductRepository.swift +// AppleStoreProductKiosk +// +// Created by 홍석현 on 9/16/25. +// + +import Combine +import DiContainer + + +class ProductRepository: 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 ?? [] + } +} diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift b/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift similarity index 100% rename from AppleStoreProductKiosk/Data/Repositories/ProductRepository.swift rename to AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift 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..465eb9e --- /dev/null +++ b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift @@ -0,0 +1,28 @@ +// +// ProductUseCase.swift +// AppleStoreProductKiosk +// +// Created by 홍석현 on 9/16/25. +// + +import Foundation + +import ComposableArchitecture +import DiContainer + +struct ProductUseCase: ProductInterface { + private let repository: ProductInterface + + init(repository: ProductInterface) { + self.repository = repository + } + + + func fetchProductCatalog() async throws -> ProductCatalog { + return try await repository.fetchProductCatalog() + } + + func fetchProducts(for category: String) async throws -> [Product] { + return try await repository.fetchProducts(for: category) + } +} From fa18181bfa4683d51e97d5a511f87e73664a6087 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:23:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20equtable=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Core/Errors/DataError.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 + } + } +} From cf50b7ef6b4957c522cd62e12966e0ebfc4a58d8 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:24:15 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20domain=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Entities/Product.swift | 20 +++++++++ .../Domain/Interface/ProductInterface.swift | 8 +++- .../Domain/UseCase/ProductUseCaseImpl.swift | 42 +++++++++++++++++-- 3 files changed, 66 insertions(+), 4 deletions(-) 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 index 260d607..2800b94 100644 --- a/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift +++ b/AppleStoreProductKiosk/Domain/Interface/ProductInterface.swift @@ -1,7 +1,13 @@ // -// Untitled.swift +// 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/UseCase/ProductUseCaseImpl.swift b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift index 465eb9e..b973536 100644 --- a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift +++ b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift @@ -1,5 +1,5 @@ // -// ProductUseCase.swift +// ProductUseCaseImpl.swift // AppleStoreProductKiosk // // Created by 홍석현 on 9/16/25. @@ -10,19 +10,55 @@ import Foundation import ComposableArchitecture import DiContainer -struct ProductUseCase: ProductInterface { +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) + } +} + +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) + } + ) + } +} From 8a2ec5332d3eb57bd941e70a4bfbd05bbbedb580 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:24:33 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20data=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/Product/AppleStoreResponseDTO.swift | 48 +++++++++++++++++++ .../Mapper+/Extension+StoreDataDTO.swift | 26 ++++++++++ .../Repositories/MockProductRepository.swift | 13 +++++ .../Repositories/ProductRepositoryImpl.swift | 13 ++++- 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift index 70c6c72..f7da533 100644 --- a/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift +++ b/AppleStoreProductKiosk/Data/Model/Product/AppleStoreResponseDTO.swift @@ -6,3 +6,51 @@ // 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 index c6126ff..782e12d 100644 --- a/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift +++ b/AppleStoreProductKiosk/Data/Model/Product/Mapper+/Extension+StoreDataDTO.swift @@ -6,3 +6,29 @@ // 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 index 27c9302..2ef54e8 100644 --- a/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift +++ b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift @@ -6,3 +6,16 @@ // import Foundation + +class MockProductRepository: ProductInterface { + func fetchProductCatalog() async throws -> ProductCatalog { + return .init(categories: []) + } + + func fetchProducts(for category: String) async throws -> [Product] { + return [] + } + + + +} diff --git a/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift index 7ac07a0..ce130bc 100644 --- a/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift +++ b/AppleStoreProductKiosk/Data/Repositories/ProductRepositoryImpl.swift @@ -1,5 +1,5 @@ // -// ProductRepository.swift +// ProductRepositoryImpl.swift // AppleStoreProductKiosk // // Created by 홍석현 on 9/16/25. @@ -9,7 +9,7 @@ import Combine import DiContainer -class ProductRepository: ProductInterface , ObservableObject { +class ProductRepositoryImpl: ProductInterface , ObservableObject { private let provider = AsyncProvider(session: .shared) func fetchProductCatalog() async throws -> ProductCatalog { @@ -24,3 +24,12 @@ class ProductRepository: ProductInterface , ObservableObject { .products ?? [] } } + + +extension RegisterModule { + var productRepositoryImplModule: () -> Module { + makeDependencyImproved(ProductInterface.self) { + ProductRepositoryImpl() + } + } +} From 6ae85d6da5dcee571632768dae9b9b86d905c607 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:24:58 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20App=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=20app=20view=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Application/AppDelegate.swift | 20 ++++++++ .../AppleStoreProductKioskApp.swift | 11 ++++- .../App/Di/Extension+AppDIContainer.swift | 14 ++++++ .../Extension+RepositoryModuleFactory.swift | 14 ++++++ ...Extension+UseCaseModuleFactory.swift.swift | 22 +++++++++ .../App/Di/ModuleFactoryManager.swift | 11 +++++ .../App/Reducer/AppReducer.swift | 46 +++++++++++++++++++ AppleStoreProductKiosk/App/View/AppView.swift | 17 ++++++- 8 files changed, 153 insertions(+), 2 deletions(-) diff --git a/AppleStoreProductKiosk/App/Application/AppDelegate.swift b/AppleStoreProductKiosk/App/Application/AppDelegate.swift index 704ad96..d134008 100644 --- a/AppleStoreProductKiosk/App/Application/AppDelegate.swift +++ b/AppleStoreProductKiosk/App/Application/AppDelegate.swift @@ -6,3 +6,23 @@ // import Foundation +import UIKit + +import DiContainer + +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + + DependencyContainer.bootstrapInTask { _ in + await AppDIContainer.shared.registerDefaultDependencies() + } + + UnifiedDI.enablePerformanceOptimization() + + return true + } +} diff --git a/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift index 815782d..7a69a95 100644 --- a/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift +++ b/AppleStoreProductKiosk/App/Application/AppleStoreProductKioskApp.swift @@ -6,12 +6,21 @@ // import SwiftUI +import ComposableArchitecture @main struct AppleStoreProductKioskApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { - ContentView() + 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 index 6c7bff4..4e5d6d1 100644 --- a/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift +++ b/AppleStoreProductKiosk/App/Di/Extension+AppDIContainer.swift @@ -6,3 +6,17 @@ // 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 index 6fc72fe..b9d1453 100644 --- a/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift +++ b/AppleStoreProductKiosk/App/Di/Extension+RepositoryModuleFactory.swift @@ -6,3 +6,17 @@ // 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 index e69de29..d288588 100644 --- a/AppleStoreProductKiosk/App/Di/Extension+UseCaseModuleFactory.swift.swift +++ 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 index e8167cd..f5d1990 100644 --- a/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift +++ b/AppleStoreProductKiosk/App/Di/ModuleFactoryManager.swift @@ -6,3 +6,14 @@ // 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 index ebe85e6..26b23e7 100644 --- a/AppleStoreProductKiosk/App/Reducer/AppReducer.swift +++ b/AppleStoreProductKiosk/App/Reducer/AppReducer.swift @@ -6,3 +6,49 @@ // 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/App/View/AppView.swift b/AppleStoreProductKiosk/App/View/AppView.swift index fd5d144..f1f7fc6 100644 --- a/AppleStoreProductKiosk/App/View/AppView.swift +++ b/AppleStoreProductKiosk/App/View/AppView.swift @@ -5,4 +5,19 @@ // Created by Wonji Suh on 9/17/25. // -import Foundation +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) + } + } + } +} From 60648042256f962916d49b79e92399de1b15957e Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:25:34 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=20ProductListFeature?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project.pbxproj | 6 +- .../AppleStoreProductKioskApp.swift | 17 -- .../Features/ContentView.swift | 11 +- .../Main/Feature/ProductListFeature.swift | 147 ++++++++++++++++-- 4 files changed, 148 insertions(+), 33 deletions(-) delete mode 100644 AppleStoreProductKiosk/Application/AppleStoreProductKioskApp.swift 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/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/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..6a6cc43 100644 --- a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift +++ b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift @@ -5,26 +5,153 @@ // 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 + + + + } + + enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } - - public enum Action { + + //MARK: - ViewAction + @CasePathable + enum View { case onTapAddProduct(id: String) + case onAppear } - - public var body: some Reducer { + + + + //MARK: - AsyncAction 비동기 처리 액션 + enum AsyncAction: Equatable { + case fetchProductCatalog + + } + + //MARK: - 앱내에서 사용하는 액션 + enum InnerAction: Equatable { + case fetchProductCatalogResponse(Result) + } + + //MARK: - NavigationAction + enum NavigationAction: Equatable { + + + } + + private struct ProductListCancel: Hashable {} + + @Dependency(\.productUseCase) var productUseCase + + var body: some ReducerOf { + BindingReducer() Reduce { state, action in switch action { - case .onTapAddProduct(let _): - return .none + case .binding(_): + 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) + + case .navigation(let navigationAction): + return handleNavigationAction(state: &state, action: navigationAction) } } } + + 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)) + } + + } + } + + 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))))) + } + + } + + } + } + + private func handleNavigationAction( + state: inout State, + action: NavigationAction + ) -> Effect { + switch action { + + } + } + + 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 + + } + } } + From c09fe220bb39067ca8b9238398c01527f2f42df5 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 16:57:10 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=E2=9C=85=20[test]:=20=20MockProductReposit?= =?UTF-8?q?oryTests=20,=20ProductUseCaseImplTests=20=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcschemes/AppleStoreProductKiosk.xcscheme | 107 ++++++++++++++++ .../Repositories/MockProductRepository.swift | 121 +++++++++++++++++- .../AppleStoreProductKioskTestPlan.xctestplan | 24 ++++ .../AppleStoreProductKioskTests.swift | 17 --- .../ProductUseCaseImplTests.swift | 32 +++++ .../MockProductRepositoryTests.swift | 36 ++++++ AppleStoreProductKioskTests/TestingTags.swift | 20 +++ 7 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 AppleStoreProductKiosk.xcodeproj/xcshareddata/xcschemes/AppleStoreProductKiosk.xcscheme create mode 100644 AppleStoreProductKioskTests/AppleStoreProductKioskTestPlan.xctestplan delete mode 100644 AppleStoreProductKioskTests/AppleStoreProductKioskTests.swift create mode 100644 AppleStoreProductKioskTests/ProductUseCaseImplTests.swift create mode 100644 AppleStoreProductKioskTests/Repository/MockProductRepositoryTests.swift create mode 100644 AppleStoreProductKioskTests/TestingTags.swift 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/Data/Repositories/MockProductRepository.swift b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift index 2ef54e8..9f7d1ce 100644 --- a/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift +++ b/AppleStoreProductKiosk/Data/Repositories/MockProductRepository.swift @@ -8,14 +8,125 @@ 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 { - return .init(categories: []) + .init(categories: categoriesData) } - + func fetchProducts(for category: String) async throws -> [Product] { - return [] + 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/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/ProductUseCaseImplTests.swift b/AppleStoreProductKioskTests/ProductUseCaseImplTests.swift new file mode 100644 index 0000000..7fda21e --- /dev/null +++ b/AppleStoreProductKioskTests/ProductUseCaseImplTests.swift @@ -0,0 +1,32 @@ +// +// ProductUseCaseImplTests.swift +// AppleStoreProductKioskTests +// +// Created by Codex CLI 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/Repository/MockProductRepositoryTests.swift b/AppleStoreProductKioskTests/Repository/MockProductRepositoryTests.swift new file mode 100644 index 0000000..1bf5418 --- /dev/null +++ b/AppleStoreProductKioskTests/Repository/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/TestingTags.swift b/AppleStoreProductKioskTests/TestingTags.swift new file mode 100644 index 0000000..cf18f14 --- /dev/null +++ b/AppleStoreProductKioskTests/TestingTags.swift @@ -0,0 +1,20 @@ +// +// TestingTags.swift +// AppleStoreProductKioskTests +// +// Defines shared tags for swift-testing +// + +import Testing + +extension Tag { + @Tag static var mock: Self + @Tag static var repository: Self + @Tag static var usecase: 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 +} + From e0fc303141605dd642186576ab3b9230ff7e8997 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 19:04:30 +0900 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=20ProductListFeature?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 테스트 코드 작성 * 필터링 구형 --- .../Domain/UseCase/ProductUseCaseImpl.swift | 1 + .../Main/Feature/ProductListFeature.swift | 63 +++++++------ .../MockProductRepositoryTests.swift | 0 .../ProductUseCaseImplTests.swift | 0 .../Feature/ProductListFeatureTest.swift | 88 +++++++++++++++++++ .../KioskProductServiceLiveTests.swift | 31 +++++++ AppleStoreProductKioskTests/TestingTags.swift | 9 +- 7 files changed, 166 insertions(+), 26 deletions(-) rename AppleStoreProductKioskTests/{Repository => Data}/MockProductRepositoryTests.swift (100%) rename AppleStoreProductKioskTests/{ => Domain}/ProductUseCaseImplTests.swift (100%) create mode 100644 AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift create mode 100644 AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift diff --git a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift index b973536..304518d 100644 --- a/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift +++ b/AppleStoreProductKiosk/Domain/UseCase/ProductUseCaseImpl.swift @@ -41,6 +41,7 @@ extension ProductUseCaseImpl: DependencyKey { } return ProductUseCaseImpl(repository: repository) } + static var testValue: ProductInterface = MockProductRepository() } extension DependencyValues { diff --git a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift index 6a6cc43..28d7d1a 100644 --- a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift +++ b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift @@ -19,17 +19,13 @@ import LogMacro init() {} var productCatalogModel : ProductCatalog? = nil - - - } - enum Action: ViewAction, BindableAction { - case binding(BindingAction) + @CasePathable + enum Action: ViewAction { case view(View) case async(AsyncAction) case inner(InnerAction) - case navigation(NavigationAction) } @@ -37,39 +33,36 @@ import LogMacro @CasePathable enum View { case onTapAddProduct(id: String) + case onSelectCategory(String) case onAppear } //MARK: - AsyncAction 비동기 처리 액션 + @CasePathable enum AsyncAction: Equatable { case fetchProductCatalog + case fetchProducts(String) } //MARK: - 앱내에서 사용하는 액션 + @CasePathable enum InnerAction: Equatable { case fetchProductCatalogResponse(Result) + case fetchProductsResponse(category: String, Result<[Product], DataError>) } - //MARK: - NavigationAction - enum NavigationAction: Equatable { - - - } private struct ProductListCancel: Hashable {} @Dependency(\.productUseCase) var productUseCase + @Dependency(\.mainQueue) var mainQueue var body: some ReducerOf { - BindingReducer() Reduce { state, action in switch action { - case .binding(_): - return .none - case .view(let viewAction): return handleViewAction(state: &state, action: viewAction) @@ -79,8 +72,6 @@ import LogMacro case .inner(let innerAction): return handleInnerAction(state: &state, action: innerAction) - case .navigation(let navigationAction): - return handleNavigationAction(state: &state, action: navigationAction) } } } @@ -97,6 +88,12 @@ import LogMacro 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))) + } } } @@ -122,14 +119,19 @@ import LogMacro } - } - } + case .fetchProducts(let category): + return .run { send in + let result = await Result { + try await productUseCase.fetchProducts(for: category) + } - private func handleNavigationAction( - state: inout State, - action: NavigationAction - ) -> Effect { - switch action { + 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))))) + } + } } } @@ -151,7 +153,18 @@ import LogMacro } 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/AppleStoreProductKioskTests/Repository/MockProductRepositoryTests.swift b/AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift similarity index 100% rename from AppleStoreProductKioskTests/Repository/MockProductRepositoryTests.swift rename to AppleStoreProductKioskTests/Data/MockProductRepositoryTests.swift diff --git a/AppleStoreProductKioskTests/ProductUseCaseImplTests.swift b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift similarity index 100% rename from AppleStoreProductKioskTests/ProductUseCaseImplTests.swift rename to AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift diff --git a/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift new file mode 100644 index 0000000..0f4e8ac --- /dev/null +++ b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift @@ -0,0 +1,88 @@ +// +// ProductListFeatureTest.swift +// AppleStoreProductKioskTests +// +// Created by Codex CLI 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..d7da193 --- /dev/null +++ b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift @@ -0,0 +1,31 @@ +// +// KioskProductServiceLiveTests.swift +// AppleStoreProductKioskTests +// +// Live API integration tests hitting the real endpoint. +// + +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 index cf18f14..055330e 100644 --- a/AppleStoreProductKioskTests/TestingTags.swift +++ b/AppleStoreProductKioskTests/TestingTags.swift @@ -11,10 +11,17 @@ 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 } - From 58e28dbb441ce4f0725d03fdd24b43827cd670f9 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Sep 2025 19:25:44 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=A8[feat]:=20=20=20ProductListFeature?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 테스트 코드 작성 --- AppleStoreProductKiosk/App/Application/AppDelegate.swift | 3 +-- .../Domain/ProductUseCaseImplTests.swift | 2 +- .../Feature/ProductListFeatureTest.swift | 2 +- .../Network/KioskProductServiceLiveTests.swift | 2 +- AppleStoreProductKioskTests/TestingTags.swift | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/AppleStoreProductKiosk/App/Application/AppDelegate.swift b/AppleStoreProductKiosk/App/Application/AppDelegate.swift index d134008..0706632 100644 --- a/AppleStoreProductKiosk/App/Application/AppDelegate.swift +++ b/AppleStoreProductKiosk/App/Application/AppDelegate.swift @@ -16,13 +16,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + UnifiedDI.enablePerformanceOptimization() DependencyContainer.bootstrapInTask { _ in await AppDIContainer.shared.registerDefaultDependencies() } - UnifiedDI.enablePerformanceOptimization() - return true } } diff --git a/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift index 7fda21e..560c26b 100644 --- a/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift +++ b/AppleStoreProductKioskTests/Domain/ProductUseCaseImplTests.swift @@ -2,7 +2,7 @@ // ProductUseCaseImplTests.swift // AppleStoreProductKioskTests // -// Created by Codex CLI on 9/17/25. +// Created by Wonji Suh on 9/17/25. // import Testing diff --git a/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift index 0f4e8ac..d42e4ba 100644 --- a/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift +++ b/AppleStoreProductKioskTests/Feature/ProductListFeatureTest.swift @@ -2,7 +2,7 @@ // ProductListFeatureTest.swift // AppleStoreProductKioskTests // -// Created by Codex CLI on 9/17/25. +// Created by Wonji Suh on 9/17/25. // import Testing diff --git a/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift index d7da193..55adb37 100644 --- a/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift +++ b/AppleStoreProductKioskTests/Network/KioskProductServiceLiveTests.swift @@ -2,7 +2,7 @@ // KioskProductServiceLiveTests.swift // AppleStoreProductKioskTests // -// Live API integration tests hitting the real endpoint. +// Created by Wonji Suh on 9/17/25. // import Testing diff --git a/AppleStoreProductKioskTests/TestingTags.swift b/AppleStoreProductKioskTests/TestingTags.swift index 055330e..8aadc0d 100644 --- a/AppleStoreProductKioskTests/TestingTags.swift +++ b/AppleStoreProductKioskTests/TestingTags.swift @@ -2,7 +2,7 @@ // TestingTags.swift // AppleStoreProductKioskTests // -// Defines shared tags for swift-testing +// Created by Wonji Suh on 9/17/25. // import Testing