@su-record/vibe 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +35 -35
- package/.claude/settings.local.json +24 -25
- package/.claude/vibe/constitution.md +184 -184
- package/.claude/vibe/rules/core/communication-guide.md +104 -104
- package/.claude/vibe/rules/core/development-philosophy.md +52 -52
- package/.claude/vibe/rules/core/quick-start.md +120 -120
- package/.claude/vibe/rules/languages/dart-flutter.md +509 -509
- package/.claude/vibe/rules/languages/go.md +396 -396
- package/.claude/vibe/rules/languages/java-spring.md +586 -586
- package/.claude/vibe/rules/languages/kotlin-android.md +491 -491
- package/.claude/vibe/rules/languages/python-django.md +371 -371
- package/.claude/vibe/rules/languages/python-fastapi.md +386 -386
- package/.claude/vibe/rules/languages/rust.md +425 -425
- package/.claude/vibe/rules/languages/swift-ios.md +516 -516
- package/.claude/vibe/rules/languages/typescript-nextjs.md +441 -441
- package/.claude/vibe/rules/languages/typescript-node.md +375 -375
- package/.claude/vibe/rules/languages/typescript-nuxt.md +521 -521
- package/.claude/vibe/rules/languages/typescript-react-native.md +446 -446
- package/.claude/vibe/rules/languages/typescript-react.md +525 -525
- package/.claude/vibe/rules/languages/typescript-vue.md +353 -353
- package/.claude/vibe/rules/quality/bdd-contract-testing.md +388 -388
- package/.claude/vibe/rules/quality/checklist.md +276 -276
- package/.claude/vibe/rules/quality/testing-strategy.md +437 -437
- package/.claude/vibe/rules/standards/anti-patterns.md +369 -369
- package/.claude/vibe/rules/standards/code-structure.md +291 -291
- package/.claude/vibe/rules/standards/complexity-metrics.md +312 -312
- package/.claude/vibe/rules/standards/naming-conventions.md +198 -198
- package/.claude/vibe/setup.sh +31 -31
- package/.claude/vibe/templates/constitution-template.md +184 -184
- package/.claude/vibe/templates/contract-backend-template.md +517 -517
- package/.claude/vibe/templates/contract-frontend-template.md +594 -594
- package/.claude/vibe/templates/feature-template.md +96 -96
- package/.claude/vibe/templates/spec-template.md +199 -199
- package/CLAUDE.md +345 -323
- package/LICENSE +21 -21
- package/README.md +744 -724
- package/agents/compounder.md +261 -261
- package/agents/diagrammer.md +178 -178
- package/agents/e2e-tester.md +266 -266
- package/agents/explorer.md +48 -48
- package/agents/implementer.md +53 -53
- package/agents/research/best-practices-agent.md +139 -139
- package/agents/research/codebase-patterns-agent.md +147 -147
- package/agents/research/framework-docs-agent.md +181 -181
- package/agents/research/security-advisory-agent.md +167 -167
- package/agents/review/architecture-reviewer.md +107 -107
- package/agents/review/complexity-reviewer.md +116 -116
- package/agents/review/data-integrity-reviewer.md +88 -88
- package/agents/review/git-history-reviewer.md +103 -103
- package/agents/review/performance-reviewer.md +86 -86
- package/agents/review/python-reviewer.md +152 -152
- package/agents/review/rails-reviewer.md +139 -139
- package/agents/review/react-reviewer.md +144 -144
- package/agents/review/security-reviewer.md +80 -80
- package/agents/review/simplicity-reviewer.md +140 -140
- package/agents/review/test-coverage-reviewer.md +116 -116
- package/agents/review/typescript-reviewer.md +127 -127
- package/agents/searcher.md +54 -54
- package/agents/simplifier.md +119 -119
- package/agents/tester.md +49 -49
- package/agents/ui-previewer.md +137 -137
- package/commands/vibe.analyze.md +245 -180
- package/commands/vibe.reason.md +223 -183
- package/commands/vibe.review.md +200 -136
- package/commands/vibe.run.md +838 -836
- package/commands/vibe.spec.md +419 -383
- package/commands/vibe.utils.md +101 -101
- package/commands/vibe.verify.md +282 -241
- package/dist/cli/index.js +385 -385
- package/dist/lib/MemoryManager.d.ts.map +1 -1
- package/dist/lib/MemoryManager.js +119 -114
- package/dist/lib/MemoryManager.js.map +1 -1
- package/dist/lib/PythonParser.js +108 -108
- package/dist/lib/gemini-mcp.js +15 -15
- package/dist/lib/gemini-oauth.js +35 -35
- package/dist/lib/gpt-mcp.js +17 -17
- package/dist/lib/gpt-oauth.js +44 -44
- package/dist/tools/analytics/getUsageAnalytics.js +12 -12
- package/dist/tools/index.d.ts +50 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +61 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/memory/createMemoryTimeline.js +10 -10
- package/dist/tools/memory/getMemoryGraph.js +12 -12
- package/dist/tools/memory/getSessionContext.js +9 -9
- package/dist/tools/memory/linkMemories.js +14 -14
- package/dist/tools/memory/listMemories.js +4 -4
- package/dist/tools/memory/recallMemory.js +4 -4
- package/dist/tools/memory/saveMemory.js +4 -4
- package/dist/tools/memory/searchMemoriesAdvanced.js +22 -22
- package/dist/tools/planning/generatePrd.js +46 -46
- package/dist/tools/prompt/enhancePromptGemini.js +160 -160
- package/dist/tools/reasoning/applyReasoningFramework.js +56 -56
- package/dist/tools/semantic/analyzeDependencyGraph.js +12 -12
- package/hooks/hooks.json +121 -103
- package/package.json +73 -69
- package/skills/git-worktree.md +178 -178
- package/skills/priority-todos.md +236 -236
|
@@ -1,516 +1,516 @@
|
|
|
1
|
-
# 🍎 Swift + iOS 품질 규칙
|
|
2
|
-
|
|
3
|
-
## 핵심 원칙 (core에서 상속)
|
|
4
|
-
|
|
5
|
-
```markdown
|
|
6
|
-
✅ 단일 책임 (SRP)
|
|
7
|
-
✅ 중복 제거 (DRY)
|
|
8
|
-
✅ 재사용성
|
|
9
|
-
✅ 낮은 복잡도
|
|
10
|
-
✅ 함수 ≤ 30줄
|
|
11
|
-
✅ 중첩 ≤ 3단계
|
|
12
|
-
✅ Cyclomatic complexity ≤ 10
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Swift/iOS 특화 규칙
|
|
16
|
-
|
|
17
|
-
### 1. SwiftUI 기본 구조
|
|
18
|
-
|
|
19
|
-
```swift
|
|
20
|
-
// ✅ View 구조
|
|
21
|
-
import SwiftUI
|
|
22
|
-
|
|
23
|
-
struct UserProfileView: View {
|
|
24
|
-
// 1. 상태 및 바인딩
|
|
25
|
-
@StateObject private var viewModel: UserProfileViewModel
|
|
26
|
-
@State private var isEditing = false
|
|
27
|
-
@Binding var selectedUser: User?
|
|
28
|
-
|
|
29
|
-
// 2. 환경 변수
|
|
30
|
-
@Environment(\.dismiss) private var dismiss
|
|
31
|
-
@EnvironmentObject private var authManager: AuthManager
|
|
32
|
-
|
|
33
|
-
// 3. Body
|
|
34
|
-
var body: some View {
|
|
35
|
-
NavigationStack {
|
|
36
|
-
content
|
|
37
|
-
.navigationTitle("프로필")
|
|
38
|
-
.toolbar { toolbarContent }
|
|
39
|
-
.sheet(isPresented: $isEditing) { editSheet }
|
|
40
|
-
}
|
|
41
|
-
.task { await viewModel.loadUser() }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// 4. 뷰 컴포넌트 분리
|
|
45
|
-
@ViewBuilder
|
|
46
|
-
private var content: some View {
|
|
47
|
-
if viewModel.isLoading {
|
|
48
|
-
ProgressView()
|
|
49
|
-
} else if let user = viewModel.user {
|
|
50
|
-
userContent(user)
|
|
51
|
-
} else {
|
|
52
|
-
emptyState
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
private func userContent(_ user: User) -> some View {
|
|
57
|
-
List {
|
|
58
|
-
Section("기본 정보") {
|
|
59
|
-
LabeledContent("이름", value: user.name)
|
|
60
|
-
LabeledContent("이메일", value: user.email)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
@ToolbarContentBuilder
|
|
66
|
-
private var toolbarContent: some ToolbarContent {
|
|
67
|
-
ToolbarItem(placement: .topBarTrailing) {
|
|
68
|
-
Button("편집") { isEditing = true }
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### 2. ViewModel (MVVM)
|
|
75
|
-
|
|
76
|
-
```swift
|
|
77
|
-
// ✅ ViewModel with @Observable (iOS 17+)
|
|
78
|
-
import Foundation
|
|
79
|
-
import Observation
|
|
80
|
-
|
|
81
|
-
@Observable
|
|
82
|
-
final class UserProfileViewModel {
|
|
83
|
-
// 상태
|
|
84
|
-
private(set) var user: User?
|
|
85
|
-
private(set) var isLoading = false
|
|
86
|
-
private(set) var error: AppError?
|
|
87
|
-
|
|
88
|
-
// 의존성
|
|
89
|
-
private let userRepository: UserRepository
|
|
90
|
-
private let userId: String
|
|
91
|
-
|
|
92
|
-
init(userId: String, userRepository: UserRepository = DefaultUserRepository()) {
|
|
93
|
-
self.userId = userId
|
|
94
|
-
self.userRepository = userRepository
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
@MainActor
|
|
98
|
-
func loadUser() async {
|
|
99
|
-
isLoading = true
|
|
100
|
-
error = nil
|
|
101
|
-
|
|
102
|
-
do {
|
|
103
|
-
user = try await userRepository.fetchUser(id: userId)
|
|
104
|
-
} catch {
|
|
105
|
-
self.error = AppError.from(error)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
isLoading = false
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
@MainActor
|
|
112
|
-
func updateUser(name: String) async throws {
|
|
113
|
-
guard var currentUser = user else { return }
|
|
114
|
-
currentUser.name = name
|
|
115
|
-
|
|
116
|
-
user = try await userRepository.updateUser(currentUser)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ✅ ViewModel with ObservableObject (iOS 13+)
|
|
121
|
-
import Combine
|
|
122
|
-
|
|
123
|
-
final class UserListViewModel: ObservableObject {
|
|
124
|
-
@Published private(set) var users: [User] = []
|
|
125
|
-
@Published private(set) var isLoading = false
|
|
126
|
-
@Published var searchText = ""
|
|
127
|
-
|
|
128
|
-
private let userRepository: UserRepository
|
|
129
|
-
private var cancellables = Set<AnyCancellable>()
|
|
130
|
-
|
|
131
|
-
var filteredUsers: [User] {
|
|
132
|
-
guard !searchText.isEmpty else { return users }
|
|
133
|
-
return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
init(userRepository: UserRepository = DefaultUserRepository()) {
|
|
137
|
-
self.userRepository = userRepository
|
|
138
|
-
setupBindings()
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private func setupBindings() {
|
|
142
|
-
$searchText
|
|
143
|
-
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
|
|
144
|
-
.sink { [weak self] _ in
|
|
145
|
-
self?.objectWillChange.send()
|
|
146
|
-
}
|
|
147
|
-
.store(in: &cancellables)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
@MainActor
|
|
151
|
-
func loadUsers() async {
|
|
152
|
-
isLoading = true
|
|
153
|
-
defer { isLoading = false }
|
|
154
|
-
|
|
155
|
-
do {
|
|
156
|
-
users = try await userRepository.fetchUsers()
|
|
157
|
-
} catch {
|
|
158
|
-
print("Error: \(error)")
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
### 3. Repository 패턴
|
|
165
|
-
|
|
166
|
-
```swift
|
|
167
|
-
// ✅ Protocol 정의
|
|
168
|
-
protocol UserRepository {
|
|
169
|
-
func fetchUsers() async throws -> [User]
|
|
170
|
-
func fetchUser(id: String) async throws -> User
|
|
171
|
-
func createUser(_ user: User) async throws -> User
|
|
172
|
-
func updateUser(_ user: User) async throws -> User
|
|
173
|
-
func deleteUser(id: String) async throws
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ✅ 구현체
|
|
177
|
-
final class DefaultUserRepository: UserRepository {
|
|
178
|
-
private let apiClient: APIClient
|
|
179
|
-
private let cache: CacheManager
|
|
180
|
-
|
|
181
|
-
init(apiClient: APIClient = .shared, cache: CacheManager = .shared) {
|
|
182
|
-
self.apiClient = apiClient
|
|
183
|
-
self.cache = cache
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
func fetchUser(id: String) async throws -> User {
|
|
187
|
-
// 캐시 확인
|
|
188
|
-
if let cached: User = cache.get(key: "user_\(id)") {
|
|
189
|
-
return cached
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// API 호출
|
|
193
|
-
let user: User = try await apiClient.request(
|
|
194
|
-
endpoint: .user(id: id),
|
|
195
|
-
method: .get
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
// 캐시 저장
|
|
199
|
-
cache.set(key: "user_\(id)", value: user, ttl: 300)
|
|
200
|
-
|
|
201
|
-
return user
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
func fetchUsers() async throws -> [User] {
|
|
205
|
-
try await apiClient.request(
|
|
206
|
-
endpoint: .users,
|
|
207
|
-
method: .get
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
### 4. 에러 처리
|
|
214
|
-
|
|
215
|
-
```swift
|
|
216
|
-
// ✅ 커스텀 에러 정의
|
|
217
|
-
enum AppError: LocalizedError {
|
|
218
|
-
case networkError(underlying: Error)
|
|
219
|
-
case decodingError(underlying: Error)
|
|
220
|
-
case notFound(resource: String, id: String)
|
|
221
|
-
case unauthorized
|
|
222
|
-
case serverError(message: String)
|
|
223
|
-
case unknown
|
|
224
|
-
|
|
225
|
-
var errorDescription: String? {
|
|
226
|
-
switch self {
|
|
227
|
-
case .networkError:
|
|
228
|
-
return "네트워크 연결을 확인해주세요"
|
|
229
|
-
case .decodingError:
|
|
230
|
-
return "데이터를 처리할 수 없습니다"
|
|
231
|
-
case .notFound(let resource, let id):
|
|
232
|
-
return "\(resource)을(를) 찾을 수 없습니다 (ID: \(id))"
|
|
233
|
-
case .unauthorized:
|
|
234
|
-
return "로그인이 필요합니다"
|
|
235
|
-
case .serverError(let message):
|
|
236
|
-
return "서버 오류: \(message)"
|
|
237
|
-
case .unknown:
|
|
238
|
-
return "알 수 없는 오류가 발생했습니다"
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
static func from(_ error: Error) -> AppError {
|
|
243
|
-
if let appError = error as? AppError {
|
|
244
|
-
return appError
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if let urlError = error as? URLError {
|
|
248
|
-
return .networkError(underlying: urlError)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if error is DecodingError {
|
|
252
|
-
return .decodingError(underlying: error)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return .unknown
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ✅ Result 타입 활용
|
|
260
|
-
func loadData() async -> Result<User, AppError> {
|
|
261
|
-
do {
|
|
262
|
-
let user = try await repository.fetchUser(id: userId)
|
|
263
|
-
return .success(user)
|
|
264
|
-
} catch {
|
|
265
|
-
return .failure(AppError.from(error))
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
### 5. 네트워킹 (async/await)
|
|
271
|
-
|
|
272
|
-
```swift
|
|
273
|
-
// ✅ API 클라이언트
|
|
274
|
-
final class APIClient {
|
|
275
|
-
static let shared = APIClient()
|
|
276
|
-
|
|
277
|
-
private let session: URLSession
|
|
278
|
-
private let decoder: JSONDecoder
|
|
279
|
-
private let baseURL: URL
|
|
280
|
-
|
|
281
|
-
init(session: URLSession = .shared, baseURL: URL = Config.apiBaseURL) {
|
|
282
|
-
self.session = session
|
|
283
|
-
self.baseURL = baseURL
|
|
284
|
-
self.decoder = JSONDecoder()
|
|
285
|
-
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
286
|
-
self.decoder.dateDecodingStrategy = .iso8601
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
func request<T: Decodable>(
|
|
290
|
-
endpoint: Endpoint,
|
|
291
|
-
method: HTTPMethod,
|
|
292
|
-
body: Encodable? = nil
|
|
293
|
-
) async throws -> T {
|
|
294
|
-
var request = URLRequest(url: baseURL.appendingPathComponent(endpoint.path))
|
|
295
|
-
request.httpMethod = method.rawValue
|
|
296
|
-
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
297
|
-
|
|
298
|
-
// 인증 토큰
|
|
299
|
-
if let token = AuthManager.shared.accessToken {
|
|
300
|
-
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Body
|
|
304
|
-
if let body {
|
|
305
|
-
request.httpBody = try JSONEncoder().encode(body)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
let (data, response) = try await session.data(for: request)
|
|
309
|
-
|
|
310
|
-
guard let httpResponse = response as? HTTPURLResponse else {
|
|
311
|
-
throw AppError.unknown
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
switch httpResponse.statusCode {
|
|
315
|
-
case 200...299:
|
|
316
|
-
return try decoder.decode(T.self, from: data)
|
|
317
|
-
case 401:
|
|
318
|
-
throw AppError.unauthorized
|
|
319
|
-
case 404:
|
|
320
|
-
throw AppError.notFound(resource: endpoint.resource, id: endpoint.id ?? "")
|
|
321
|
-
default:
|
|
322
|
-
throw AppError.serverError(message: "Status: \(httpResponse.statusCode)")
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// ✅ Endpoint 정의
|
|
328
|
-
enum Endpoint {
|
|
329
|
-
case users
|
|
330
|
-
case user(id: String)
|
|
331
|
-
case createUser
|
|
332
|
-
case updateUser(id: String)
|
|
333
|
-
|
|
334
|
-
var path: String {
|
|
335
|
-
switch self {
|
|
336
|
-
case .users, .createUser:
|
|
337
|
-
return "/users"
|
|
338
|
-
case .user(let id), .updateUser(let id):
|
|
339
|
-
return "/users/\(id)"
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
var resource: String { "User" }
|
|
344
|
-
|
|
345
|
-
var id: String? {
|
|
346
|
-
switch self {
|
|
347
|
-
case .user(let id), .updateUser(let id):
|
|
348
|
-
return id
|
|
349
|
-
default:
|
|
350
|
-
return nil
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
### 6. 의존성 주입
|
|
357
|
-
|
|
358
|
-
```swift
|
|
359
|
-
// ✅ Environment를 통한 DI (SwiftUI)
|
|
360
|
-
private struct UserRepositoryKey: EnvironmentKey {
|
|
361
|
-
static let defaultValue: UserRepository = DefaultUserRepository()
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
extension EnvironmentValues {
|
|
365
|
-
var userRepository: UserRepository {
|
|
366
|
-
get { self[UserRepositoryKey.self] }
|
|
367
|
-
set { self[UserRepositoryKey.self] = newValue }
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// 사용
|
|
372
|
-
struct ContentView: View {
|
|
373
|
-
@Environment(\.userRepository) private var userRepository
|
|
374
|
-
|
|
375
|
-
var body: some View {
|
|
376
|
-
UserListView(viewModel: UserListViewModel(userRepository: userRepository))
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ✅ Container 패턴
|
|
381
|
-
final class DIContainer {
|
|
382
|
-
static let shared = DIContainer()
|
|
383
|
-
|
|
384
|
-
lazy var userRepository: UserRepository = DefaultUserRepository(
|
|
385
|
-
apiClient: apiClient,
|
|
386
|
-
cache: cacheManager
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
lazy var apiClient: APIClient = APIClient()
|
|
390
|
-
lazy var cacheManager: CacheManager = CacheManager()
|
|
391
|
-
|
|
392
|
-
private init() {}
|
|
393
|
-
}
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
### 7. 테스트
|
|
397
|
-
|
|
398
|
-
```swift
|
|
399
|
-
import XCTest
|
|
400
|
-
@testable import MyApp
|
|
401
|
-
|
|
402
|
-
// ✅ Mock Repository
|
|
403
|
-
final class MockUserRepository: UserRepository {
|
|
404
|
-
var fetchUsersResult: Result<[User], Error> = .success([])
|
|
405
|
-
var fetchUserResult: Result<User, Error> = .failure(AppError.notFound(resource: "User", id: ""))
|
|
406
|
-
|
|
407
|
-
func fetchUsers() async throws -> [User] {
|
|
408
|
-
try fetchUsersResult.get()
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
func fetchUser(id: String) async throws -> User {
|
|
412
|
-
try fetchUserResult.get()
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// ... 다른 메서드
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// ✅ ViewModel 테스트
|
|
419
|
-
final class UserListViewModelTests: XCTestCase {
|
|
420
|
-
var sut: UserListViewModel!
|
|
421
|
-
var mockRepository: MockUserRepository!
|
|
422
|
-
|
|
423
|
-
override func setUp() {
|
|
424
|
-
super.setUp()
|
|
425
|
-
mockRepository = MockUserRepository()
|
|
426
|
-
sut = UserListViewModel(userRepository: mockRepository)
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
override func tearDown() {
|
|
430
|
-
sut = nil
|
|
431
|
-
mockRepository = nil
|
|
432
|
-
super.tearDown()
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
func test_loadUsers_성공시_users가_업데이트된다() async {
|
|
436
|
-
// Given
|
|
437
|
-
let expectedUsers = [
|
|
438
|
-
User(id: "1", name: "테스트1", email: "test1@example.com"),
|
|
439
|
-
User(id: "2", name: "테스트2", email: "test2@example.com")
|
|
440
|
-
]
|
|
441
|
-
mockRepository.fetchUsersResult = .success(expectedUsers)
|
|
442
|
-
|
|
443
|
-
// When
|
|
444
|
-
await sut.loadUsers()
|
|
445
|
-
|
|
446
|
-
// Then
|
|
447
|
-
XCTAssertEqual(sut.users.count, 2)
|
|
448
|
-
XCTAssertFalse(sut.isLoading)
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
func test_filteredUsers_검색어가_있으면_필터링된다() {
|
|
452
|
-
// Given
|
|
453
|
-
sut.users = [
|
|
454
|
-
User(id: "1", name: "홍길동", email: "hong@example.com"),
|
|
455
|
-
User(id: "2", name: "김철수", email: "kim@example.com")
|
|
456
|
-
]
|
|
457
|
-
|
|
458
|
-
// When
|
|
459
|
-
sut.searchText = "홍"
|
|
460
|
-
|
|
461
|
-
// Then
|
|
462
|
-
XCTAssertEqual(sut.filteredUsers.count, 1)
|
|
463
|
-
XCTAssertEqual(sut.filteredUsers.first?.name, "홍길동")
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
## 파일 구조
|
|
469
|
-
|
|
470
|
-
```
|
|
471
|
-
Project/
|
|
472
|
-
├── App/
|
|
473
|
-
│ ├── ProjectApp.swift # 앱 진입점
|
|
474
|
-
│ └── DIContainer.swift # 의존성 컨테이너
|
|
475
|
-
├── Features/
|
|
476
|
-
│ ├── Auth/
|
|
477
|
-
│ │ ├── Views/
|
|
478
|
-
│ │ ├── ViewModels/
|
|
479
|
-
│ │ └── Models/
|
|
480
|
-
│ └── User/
|
|
481
|
-
│ ├── Views/
|
|
482
|
-
│ │ ├── UserListView.swift
|
|
483
|
-
│ │ └── UserDetailView.swift
|
|
484
|
-
│ ├── ViewModels/
|
|
485
|
-
│ │ └── UserListViewModel.swift
|
|
486
|
-
│ └── Models/
|
|
487
|
-
│ └── User.swift
|
|
488
|
-
├── Core/
|
|
489
|
-
│ ├── Network/
|
|
490
|
-
│ │ ├── APIClient.swift
|
|
491
|
-
│ │ └── Endpoint.swift
|
|
492
|
-
│ ├── Storage/
|
|
493
|
-
│ │ └── CacheManager.swift
|
|
494
|
-
│ └── Utils/
|
|
495
|
-
│ └── Extensions/
|
|
496
|
-
├── Repositories/
|
|
497
|
-
│ ├── UserRepository.swift
|
|
498
|
-
│ └── Implementations/
|
|
499
|
-
├── Resources/
|
|
500
|
-
│ ├── Assets.xcassets
|
|
501
|
-
│ └── Localizable.strings
|
|
502
|
-
└── Tests/
|
|
503
|
-
├── UnitTests/
|
|
504
|
-
└── UITests/
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
## 체크리스트
|
|
508
|
-
|
|
509
|
-
- [ ] @Observable 또는 @ObservableObject 사용
|
|
510
|
-
- [ ] MVVM 패턴 준수
|
|
511
|
-
- [ ] async/await로 비동기 처리
|
|
512
|
-
- [ ] Protocol로 의존성 추상화
|
|
513
|
-
- [ ] @MainActor로 UI 업데이트 보장
|
|
514
|
-
- [ ] LocalizedError로 에러 메시지 정의
|
|
515
|
-
- [ ] @ViewBuilder로 조건부 뷰 분리
|
|
516
|
-
- [ ] 테스트 가능한 구조 (Mock 주입)
|
|
1
|
+
# 🍎 Swift + iOS 품질 규칙
|
|
2
|
+
|
|
3
|
+
## 핵심 원칙 (core에서 상속)
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
✅ 단일 책임 (SRP)
|
|
7
|
+
✅ 중복 제거 (DRY)
|
|
8
|
+
✅ 재사용성
|
|
9
|
+
✅ 낮은 복잡도
|
|
10
|
+
✅ 함수 ≤ 30줄
|
|
11
|
+
✅ 중첩 ≤ 3단계
|
|
12
|
+
✅ Cyclomatic complexity ≤ 10
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Swift/iOS 특화 규칙
|
|
16
|
+
|
|
17
|
+
### 1. SwiftUI 기본 구조
|
|
18
|
+
|
|
19
|
+
```swift
|
|
20
|
+
// ✅ View 구조
|
|
21
|
+
import SwiftUI
|
|
22
|
+
|
|
23
|
+
struct UserProfileView: View {
|
|
24
|
+
// 1. 상태 및 바인딩
|
|
25
|
+
@StateObject private var viewModel: UserProfileViewModel
|
|
26
|
+
@State private var isEditing = false
|
|
27
|
+
@Binding var selectedUser: User?
|
|
28
|
+
|
|
29
|
+
// 2. 환경 변수
|
|
30
|
+
@Environment(\.dismiss) private var dismiss
|
|
31
|
+
@EnvironmentObject private var authManager: AuthManager
|
|
32
|
+
|
|
33
|
+
// 3. Body
|
|
34
|
+
var body: some View {
|
|
35
|
+
NavigationStack {
|
|
36
|
+
content
|
|
37
|
+
.navigationTitle("프로필")
|
|
38
|
+
.toolbar { toolbarContent }
|
|
39
|
+
.sheet(isPresented: $isEditing) { editSheet }
|
|
40
|
+
}
|
|
41
|
+
.task { await viewModel.loadUser() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 4. 뷰 컴포넌트 분리
|
|
45
|
+
@ViewBuilder
|
|
46
|
+
private var content: some View {
|
|
47
|
+
if viewModel.isLoading {
|
|
48
|
+
ProgressView()
|
|
49
|
+
} else if let user = viewModel.user {
|
|
50
|
+
userContent(user)
|
|
51
|
+
} else {
|
|
52
|
+
emptyState
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func userContent(_ user: User) -> some View {
|
|
57
|
+
List {
|
|
58
|
+
Section("기본 정보") {
|
|
59
|
+
LabeledContent("이름", value: user.name)
|
|
60
|
+
LabeledContent("이메일", value: user.email)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@ToolbarContentBuilder
|
|
66
|
+
private var toolbarContent: some ToolbarContent {
|
|
67
|
+
ToolbarItem(placement: .topBarTrailing) {
|
|
68
|
+
Button("편집") { isEditing = true }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2. ViewModel (MVVM)
|
|
75
|
+
|
|
76
|
+
```swift
|
|
77
|
+
// ✅ ViewModel with @Observable (iOS 17+)
|
|
78
|
+
import Foundation
|
|
79
|
+
import Observation
|
|
80
|
+
|
|
81
|
+
@Observable
|
|
82
|
+
final class UserProfileViewModel {
|
|
83
|
+
// 상태
|
|
84
|
+
private(set) var user: User?
|
|
85
|
+
private(set) var isLoading = false
|
|
86
|
+
private(set) var error: AppError?
|
|
87
|
+
|
|
88
|
+
// 의존성
|
|
89
|
+
private let userRepository: UserRepository
|
|
90
|
+
private let userId: String
|
|
91
|
+
|
|
92
|
+
init(userId: String, userRepository: UserRepository = DefaultUserRepository()) {
|
|
93
|
+
self.userId = userId
|
|
94
|
+
self.userRepository = userRepository
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@MainActor
|
|
98
|
+
func loadUser() async {
|
|
99
|
+
isLoading = true
|
|
100
|
+
error = nil
|
|
101
|
+
|
|
102
|
+
do {
|
|
103
|
+
user = try await userRepository.fetchUser(id: userId)
|
|
104
|
+
} catch {
|
|
105
|
+
self.error = AppError.from(error)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isLoading = false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@MainActor
|
|
112
|
+
func updateUser(name: String) async throws {
|
|
113
|
+
guard var currentUser = user else { return }
|
|
114
|
+
currentUser.name = name
|
|
115
|
+
|
|
116
|
+
user = try await userRepository.updateUser(currentUser)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ✅ ViewModel with ObservableObject (iOS 13+)
|
|
121
|
+
import Combine
|
|
122
|
+
|
|
123
|
+
final class UserListViewModel: ObservableObject {
|
|
124
|
+
@Published private(set) var users: [User] = []
|
|
125
|
+
@Published private(set) var isLoading = false
|
|
126
|
+
@Published var searchText = ""
|
|
127
|
+
|
|
128
|
+
private let userRepository: UserRepository
|
|
129
|
+
private var cancellables = Set<AnyCancellable>()
|
|
130
|
+
|
|
131
|
+
var filteredUsers: [User] {
|
|
132
|
+
guard !searchText.isEmpty else { return users }
|
|
133
|
+
return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
init(userRepository: UserRepository = DefaultUserRepository()) {
|
|
137
|
+
self.userRepository = userRepository
|
|
138
|
+
setupBindings()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private func setupBindings() {
|
|
142
|
+
$searchText
|
|
143
|
+
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
|
|
144
|
+
.sink { [weak self] _ in
|
|
145
|
+
self?.objectWillChange.send()
|
|
146
|
+
}
|
|
147
|
+
.store(in: &cancellables)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@MainActor
|
|
151
|
+
func loadUsers() async {
|
|
152
|
+
isLoading = true
|
|
153
|
+
defer { isLoading = false }
|
|
154
|
+
|
|
155
|
+
do {
|
|
156
|
+
users = try await userRepository.fetchUsers()
|
|
157
|
+
} catch {
|
|
158
|
+
print("Error: \(error)")
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### 3. Repository 패턴
|
|
165
|
+
|
|
166
|
+
```swift
|
|
167
|
+
// ✅ Protocol 정의
|
|
168
|
+
protocol UserRepository {
|
|
169
|
+
func fetchUsers() async throws -> [User]
|
|
170
|
+
func fetchUser(id: String) async throws -> User
|
|
171
|
+
func createUser(_ user: User) async throws -> User
|
|
172
|
+
func updateUser(_ user: User) async throws -> User
|
|
173
|
+
func deleteUser(id: String) async throws
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ✅ 구현체
|
|
177
|
+
final class DefaultUserRepository: UserRepository {
|
|
178
|
+
private let apiClient: APIClient
|
|
179
|
+
private let cache: CacheManager
|
|
180
|
+
|
|
181
|
+
init(apiClient: APIClient = .shared, cache: CacheManager = .shared) {
|
|
182
|
+
self.apiClient = apiClient
|
|
183
|
+
self.cache = cache
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func fetchUser(id: String) async throws -> User {
|
|
187
|
+
// 캐시 확인
|
|
188
|
+
if let cached: User = cache.get(key: "user_\(id)") {
|
|
189
|
+
return cached
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// API 호출
|
|
193
|
+
let user: User = try await apiClient.request(
|
|
194
|
+
endpoint: .user(id: id),
|
|
195
|
+
method: .get
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
// 캐시 저장
|
|
199
|
+
cache.set(key: "user_\(id)", value: user, ttl: 300)
|
|
200
|
+
|
|
201
|
+
return user
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func fetchUsers() async throws -> [User] {
|
|
205
|
+
try await apiClient.request(
|
|
206
|
+
endpoint: .users,
|
|
207
|
+
method: .get
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 4. 에러 처리
|
|
214
|
+
|
|
215
|
+
```swift
|
|
216
|
+
// ✅ 커스텀 에러 정의
|
|
217
|
+
enum AppError: LocalizedError {
|
|
218
|
+
case networkError(underlying: Error)
|
|
219
|
+
case decodingError(underlying: Error)
|
|
220
|
+
case notFound(resource: String, id: String)
|
|
221
|
+
case unauthorized
|
|
222
|
+
case serverError(message: String)
|
|
223
|
+
case unknown
|
|
224
|
+
|
|
225
|
+
var errorDescription: String? {
|
|
226
|
+
switch self {
|
|
227
|
+
case .networkError:
|
|
228
|
+
return "네트워크 연결을 확인해주세요"
|
|
229
|
+
case .decodingError:
|
|
230
|
+
return "데이터를 처리할 수 없습니다"
|
|
231
|
+
case .notFound(let resource, let id):
|
|
232
|
+
return "\(resource)을(를) 찾을 수 없습니다 (ID: \(id))"
|
|
233
|
+
case .unauthorized:
|
|
234
|
+
return "로그인이 필요합니다"
|
|
235
|
+
case .serverError(let message):
|
|
236
|
+
return "서버 오류: \(message)"
|
|
237
|
+
case .unknown:
|
|
238
|
+
return "알 수 없는 오류가 발생했습니다"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
static func from(_ error: Error) -> AppError {
|
|
243
|
+
if let appError = error as? AppError {
|
|
244
|
+
return appError
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if let urlError = error as? URLError {
|
|
248
|
+
return .networkError(underlying: urlError)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if error is DecodingError {
|
|
252
|
+
return .decodingError(underlying: error)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return .unknown
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ✅ Result 타입 활용
|
|
260
|
+
func loadData() async -> Result<User, AppError> {
|
|
261
|
+
do {
|
|
262
|
+
let user = try await repository.fetchUser(id: userId)
|
|
263
|
+
return .success(user)
|
|
264
|
+
} catch {
|
|
265
|
+
return .failure(AppError.from(error))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### 5. 네트워킹 (async/await)
|
|
271
|
+
|
|
272
|
+
```swift
|
|
273
|
+
// ✅ API 클라이언트
|
|
274
|
+
final class APIClient {
|
|
275
|
+
static let shared = APIClient()
|
|
276
|
+
|
|
277
|
+
private let session: URLSession
|
|
278
|
+
private let decoder: JSONDecoder
|
|
279
|
+
private let baseURL: URL
|
|
280
|
+
|
|
281
|
+
init(session: URLSession = .shared, baseURL: URL = Config.apiBaseURL) {
|
|
282
|
+
self.session = session
|
|
283
|
+
self.baseURL = baseURL
|
|
284
|
+
self.decoder = JSONDecoder()
|
|
285
|
+
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
286
|
+
self.decoder.dateDecodingStrategy = .iso8601
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
func request<T: Decodable>(
|
|
290
|
+
endpoint: Endpoint,
|
|
291
|
+
method: HTTPMethod,
|
|
292
|
+
body: Encodable? = nil
|
|
293
|
+
) async throws -> T {
|
|
294
|
+
var request = URLRequest(url: baseURL.appendingPathComponent(endpoint.path))
|
|
295
|
+
request.httpMethod = method.rawValue
|
|
296
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
297
|
+
|
|
298
|
+
// 인증 토큰
|
|
299
|
+
if let token = AuthManager.shared.accessToken {
|
|
300
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Body
|
|
304
|
+
if let body {
|
|
305
|
+
request.httpBody = try JSONEncoder().encode(body)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let (data, response) = try await session.data(for: request)
|
|
309
|
+
|
|
310
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
311
|
+
throw AppError.unknown
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
switch httpResponse.statusCode {
|
|
315
|
+
case 200...299:
|
|
316
|
+
return try decoder.decode(T.self, from: data)
|
|
317
|
+
case 401:
|
|
318
|
+
throw AppError.unauthorized
|
|
319
|
+
case 404:
|
|
320
|
+
throw AppError.notFound(resource: endpoint.resource, id: endpoint.id ?? "")
|
|
321
|
+
default:
|
|
322
|
+
throw AppError.serverError(message: "Status: \(httpResponse.statusCode)")
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ✅ Endpoint 정의
|
|
328
|
+
enum Endpoint {
|
|
329
|
+
case users
|
|
330
|
+
case user(id: String)
|
|
331
|
+
case createUser
|
|
332
|
+
case updateUser(id: String)
|
|
333
|
+
|
|
334
|
+
var path: String {
|
|
335
|
+
switch self {
|
|
336
|
+
case .users, .createUser:
|
|
337
|
+
return "/users"
|
|
338
|
+
case .user(let id), .updateUser(let id):
|
|
339
|
+
return "/users/\(id)"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
var resource: String { "User" }
|
|
344
|
+
|
|
345
|
+
var id: String? {
|
|
346
|
+
switch self {
|
|
347
|
+
case .user(let id), .updateUser(let id):
|
|
348
|
+
return id
|
|
349
|
+
default:
|
|
350
|
+
return nil
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### 6. 의존성 주입
|
|
357
|
+
|
|
358
|
+
```swift
|
|
359
|
+
// ✅ Environment를 통한 DI (SwiftUI)
|
|
360
|
+
private struct UserRepositoryKey: EnvironmentKey {
|
|
361
|
+
static let defaultValue: UserRepository = DefaultUserRepository()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
extension EnvironmentValues {
|
|
365
|
+
var userRepository: UserRepository {
|
|
366
|
+
get { self[UserRepositoryKey.self] }
|
|
367
|
+
set { self[UserRepositoryKey.self] = newValue }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 사용
|
|
372
|
+
struct ContentView: View {
|
|
373
|
+
@Environment(\.userRepository) private var userRepository
|
|
374
|
+
|
|
375
|
+
var body: some View {
|
|
376
|
+
UserListView(viewModel: UserListViewModel(userRepository: userRepository))
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ✅ Container 패턴
|
|
381
|
+
final class DIContainer {
|
|
382
|
+
static let shared = DIContainer()
|
|
383
|
+
|
|
384
|
+
lazy var userRepository: UserRepository = DefaultUserRepository(
|
|
385
|
+
apiClient: apiClient,
|
|
386
|
+
cache: cacheManager
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
lazy var apiClient: APIClient = APIClient()
|
|
390
|
+
lazy var cacheManager: CacheManager = CacheManager()
|
|
391
|
+
|
|
392
|
+
private init() {}
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### 7. 테스트
|
|
397
|
+
|
|
398
|
+
```swift
|
|
399
|
+
import XCTest
|
|
400
|
+
@testable import MyApp
|
|
401
|
+
|
|
402
|
+
// ✅ Mock Repository
|
|
403
|
+
final class MockUserRepository: UserRepository {
|
|
404
|
+
var fetchUsersResult: Result<[User], Error> = .success([])
|
|
405
|
+
var fetchUserResult: Result<User, Error> = .failure(AppError.notFound(resource: "User", id: ""))
|
|
406
|
+
|
|
407
|
+
func fetchUsers() async throws -> [User] {
|
|
408
|
+
try fetchUsersResult.get()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func fetchUser(id: String) async throws -> User {
|
|
412
|
+
try fetchUserResult.get()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ... 다른 메서드
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ✅ ViewModel 테스트
|
|
419
|
+
final class UserListViewModelTests: XCTestCase {
|
|
420
|
+
var sut: UserListViewModel!
|
|
421
|
+
var mockRepository: MockUserRepository!
|
|
422
|
+
|
|
423
|
+
override func setUp() {
|
|
424
|
+
super.setUp()
|
|
425
|
+
mockRepository = MockUserRepository()
|
|
426
|
+
sut = UserListViewModel(userRepository: mockRepository)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
override func tearDown() {
|
|
430
|
+
sut = nil
|
|
431
|
+
mockRepository = nil
|
|
432
|
+
super.tearDown()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func test_loadUsers_성공시_users가_업데이트된다() async {
|
|
436
|
+
// Given
|
|
437
|
+
let expectedUsers = [
|
|
438
|
+
User(id: "1", name: "테스트1", email: "test1@example.com"),
|
|
439
|
+
User(id: "2", name: "테스트2", email: "test2@example.com")
|
|
440
|
+
]
|
|
441
|
+
mockRepository.fetchUsersResult = .success(expectedUsers)
|
|
442
|
+
|
|
443
|
+
// When
|
|
444
|
+
await sut.loadUsers()
|
|
445
|
+
|
|
446
|
+
// Then
|
|
447
|
+
XCTAssertEqual(sut.users.count, 2)
|
|
448
|
+
XCTAssertFalse(sut.isLoading)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func test_filteredUsers_검색어가_있으면_필터링된다() {
|
|
452
|
+
// Given
|
|
453
|
+
sut.users = [
|
|
454
|
+
User(id: "1", name: "홍길동", email: "hong@example.com"),
|
|
455
|
+
User(id: "2", name: "김철수", email: "kim@example.com")
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
// When
|
|
459
|
+
sut.searchText = "홍"
|
|
460
|
+
|
|
461
|
+
// Then
|
|
462
|
+
XCTAssertEqual(sut.filteredUsers.count, 1)
|
|
463
|
+
XCTAssertEqual(sut.filteredUsers.first?.name, "홍길동")
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
## 파일 구조
|
|
469
|
+
|
|
470
|
+
```
|
|
471
|
+
Project/
|
|
472
|
+
├── App/
|
|
473
|
+
│ ├── ProjectApp.swift # 앱 진입점
|
|
474
|
+
│ └── DIContainer.swift # 의존성 컨테이너
|
|
475
|
+
├── Features/
|
|
476
|
+
│ ├── Auth/
|
|
477
|
+
│ │ ├── Views/
|
|
478
|
+
│ │ ├── ViewModels/
|
|
479
|
+
│ │ └── Models/
|
|
480
|
+
│ └── User/
|
|
481
|
+
│ ├── Views/
|
|
482
|
+
│ │ ├── UserListView.swift
|
|
483
|
+
│ │ └── UserDetailView.swift
|
|
484
|
+
│ ├── ViewModels/
|
|
485
|
+
│ │ └── UserListViewModel.swift
|
|
486
|
+
│ └── Models/
|
|
487
|
+
│ └── User.swift
|
|
488
|
+
├── Core/
|
|
489
|
+
│ ├── Network/
|
|
490
|
+
│ │ ├── APIClient.swift
|
|
491
|
+
│ │ └── Endpoint.swift
|
|
492
|
+
│ ├── Storage/
|
|
493
|
+
│ │ └── CacheManager.swift
|
|
494
|
+
│ └── Utils/
|
|
495
|
+
│ └── Extensions/
|
|
496
|
+
├── Repositories/
|
|
497
|
+
│ ├── UserRepository.swift
|
|
498
|
+
│ └── Implementations/
|
|
499
|
+
├── Resources/
|
|
500
|
+
│ ├── Assets.xcassets
|
|
501
|
+
│ └── Localizable.strings
|
|
502
|
+
└── Tests/
|
|
503
|
+
├── UnitTests/
|
|
504
|
+
└── UITests/
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
## 체크리스트
|
|
508
|
+
|
|
509
|
+
- [ ] @Observable 또는 @ObservableObject 사용
|
|
510
|
+
- [ ] MVVM 패턴 준수
|
|
511
|
+
- [ ] async/await로 비동기 처리
|
|
512
|
+
- [ ] Protocol로 의존성 추상화
|
|
513
|
+
- [ ] @MainActor로 UI 업데이트 보장
|
|
514
|
+
- [ ] LocalizedError로 에러 메시지 정의
|
|
515
|
+
- [ ] @ViewBuilder로 조건부 뷰 분리
|
|
516
|
+
- [ ] 테스트 가능한 구조 (Mock 주입)
|