@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,491 +1,491 @@
|
|
|
1
|
-
# 🤖 Kotlin + Android 품질 규칙
|
|
2
|
-
|
|
3
|
-
## 핵심 원칙 (core에서 상속)
|
|
4
|
-
|
|
5
|
-
```markdown
|
|
6
|
-
✅ 단일 책임 (SRP)
|
|
7
|
-
✅ 중복 제거 (DRY)
|
|
8
|
-
✅ 재사용성
|
|
9
|
-
✅ 낮은 복잡도
|
|
10
|
-
✅ 함수 ≤ 30줄
|
|
11
|
-
✅ 중첩 ≤ 3단계
|
|
12
|
-
✅ Cyclomatic complexity ≤ 10
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Kotlin/Android 특화 규칙
|
|
16
|
-
|
|
17
|
-
### 1. Jetpack Compose UI
|
|
18
|
-
|
|
19
|
-
```kotlin
|
|
20
|
-
// ✅ Composable 함수
|
|
21
|
-
@Composable
|
|
22
|
-
fun UserProfileScreen(
|
|
23
|
-
viewModel: UserProfileViewModel = hiltViewModel(),
|
|
24
|
-
onNavigateBack: () -> Unit
|
|
25
|
-
) {
|
|
26
|
-
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
27
|
-
|
|
28
|
-
UserProfileContent(
|
|
29
|
-
uiState = uiState,
|
|
30
|
-
onRefresh = viewModel::loadUser,
|
|
31
|
-
onNavigateBack = onNavigateBack
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ✅ Stateless Composable (재사용 가능)
|
|
36
|
-
@Composable
|
|
37
|
-
private fun UserProfileContent(
|
|
38
|
-
uiState: UserProfileUiState,
|
|
39
|
-
onRefresh: () -> Unit,
|
|
40
|
-
onNavigateBack: () -> Unit,
|
|
41
|
-
modifier: Modifier = Modifier
|
|
42
|
-
) {
|
|
43
|
-
Scaffold(
|
|
44
|
-
topBar = {
|
|
45
|
-
TopAppBar(
|
|
46
|
-
title = { Text("프로필") },
|
|
47
|
-
navigationIcon = {
|
|
48
|
-
IconButton(onClick = onNavigateBack) {
|
|
49
|
-
Icon(Icons.Default.ArrowBack, contentDescription = "뒤로")
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
) { paddingValues ->
|
|
55
|
-
when (uiState) {
|
|
56
|
-
is UserProfileUiState.Loading -> LoadingContent(modifier.padding(paddingValues))
|
|
57
|
-
is UserProfileUiState.Success -> UserContent(
|
|
58
|
-
user = uiState.user,
|
|
59
|
-
modifier = modifier.padding(paddingValues)
|
|
60
|
-
)
|
|
61
|
-
is UserProfileUiState.Error -> ErrorContent(
|
|
62
|
-
message = uiState.message,
|
|
63
|
-
onRetry = onRefresh,
|
|
64
|
-
modifier = modifier.padding(paddingValues)
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ✅ 재사용 가능한 컴포넌트
|
|
71
|
-
@Composable
|
|
72
|
-
fun UserCard(
|
|
73
|
-
user: User,
|
|
74
|
-
onClick: () -> Unit,
|
|
75
|
-
modifier: Modifier = Modifier
|
|
76
|
-
) {
|
|
77
|
-
Card(
|
|
78
|
-
modifier = modifier
|
|
79
|
-
.fillMaxWidth()
|
|
80
|
-
.clickable(onClick = onClick),
|
|
81
|
-
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
|
82
|
-
) {
|
|
83
|
-
Row(
|
|
84
|
-
modifier = Modifier.padding(16.dp),
|
|
85
|
-
verticalAlignment = Alignment.CenterVertically
|
|
86
|
-
) {
|
|
87
|
-
AsyncImage(
|
|
88
|
-
model = user.profileImage,
|
|
89
|
-
contentDescription = "${user.name} 프로필",
|
|
90
|
-
modifier = Modifier
|
|
91
|
-
.size(48.dp)
|
|
92
|
-
.clip(CircleShape)
|
|
93
|
-
)
|
|
94
|
-
Spacer(modifier = Modifier.width(16.dp))
|
|
95
|
-
Column {
|
|
96
|
-
Text(
|
|
97
|
-
text = user.name,
|
|
98
|
-
style = MaterialTheme.typography.titleMedium
|
|
99
|
-
)
|
|
100
|
-
Text(
|
|
101
|
-
text = user.email,
|
|
102
|
-
style = MaterialTheme.typography.bodySmall,
|
|
103
|
-
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### 2. ViewModel (MVVM)
|
|
112
|
-
|
|
113
|
-
```kotlin
|
|
114
|
-
// ✅ UiState 정의 (Sealed Interface)
|
|
115
|
-
sealed interface UserListUiState {
|
|
116
|
-
data object Loading : UserListUiState
|
|
117
|
-
data class Success(
|
|
118
|
-
val users: List<User>,
|
|
119
|
-
val isRefreshing: Boolean = false
|
|
120
|
-
) : UserListUiState
|
|
121
|
-
data class Error(val message: String) : UserListUiState
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ✅ ViewModel with Hilt
|
|
125
|
-
@HiltViewModel
|
|
126
|
-
class UserListViewModel @Inject constructor(
|
|
127
|
-
private val getUsersUseCase: GetUsersUseCase,
|
|
128
|
-
private val savedStateHandle: SavedStateHandle
|
|
129
|
-
) : ViewModel() {
|
|
130
|
-
|
|
131
|
-
private val _uiState = MutableStateFlow<UserListUiState>(UserListUiState.Loading)
|
|
132
|
-
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
|
|
133
|
-
|
|
134
|
-
private val searchQuery = savedStateHandle.getStateFlow("search", "")
|
|
135
|
-
|
|
136
|
-
val filteredUsers: StateFlow<List<User>> = combine(
|
|
137
|
-
_uiState,
|
|
138
|
-
searchQuery
|
|
139
|
-
) { state, query ->
|
|
140
|
-
when (state) {
|
|
141
|
-
is UserListUiState.Success -> {
|
|
142
|
-
if (query.isBlank()) state.users
|
|
143
|
-
else state.users.filter { it.name.contains(query, ignoreCase = true) }
|
|
144
|
-
}
|
|
145
|
-
else -> emptyList()
|
|
146
|
-
}
|
|
147
|
-
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
148
|
-
|
|
149
|
-
init {
|
|
150
|
-
loadUsers()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
fun loadUsers() {
|
|
154
|
-
viewModelScope.launch {
|
|
155
|
-
_uiState.value = UserListUiState.Loading
|
|
156
|
-
|
|
157
|
-
getUsersUseCase()
|
|
158
|
-
.onSuccess { users ->
|
|
159
|
-
_uiState.value = UserListUiState.Success(users)
|
|
160
|
-
}
|
|
161
|
-
.onFailure { error ->
|
|
162
|
-
_uiState.value = UserListUiState.Error(
|
|
163
|
-
error.message ?: "사용자 목록을 불러올 수 없습니다"
|
|
164
|
-
)
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
fun updateSearchQuery(query: String) {
|
|
170
|
-
savedStateHandle["search"] = query
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
fun refresh() {
|
|
174
|
-
viewModelScope.launch {
|
|
175
|
-
val currentState = _uiState.value
|
|
176
|
-
if (currentState is UserListUiState.Success) {
|
|
177
|
-
_uiState.value = currentState.copy(isRefreshing = true)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
getUsersUseCase()
|
|
181
|
-
.onSuccess { users ->
|
|
182
|
-
_uiState.value = UserListUiState.Success(users, isRefreshing = false)
|
|
183
|
-
}
|
|
184
|
-
.onFailure { /* 에러 처리 */ }
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
### 3. UseCase (Clean Architecture)
|
|
191
|
-
|
|
192
|
-
```kotlin
|
|
193
|
-
// ✅ UseCase 정의
|
|
194
|
-
class GetUsersUseCase @Inject constructor(
|
|
195
|
-
private val userRepository: UserRepository,
|
|
196
|
-
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
|
197
|
-
) {
|
|
198
|
-
suspend operator fun invoke(): Result<List<User>> = withContext(dispatcher) {
|
|
199
|
-
runCatching {
|
|
200
|
-
userRepository.getUsers()
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
class GetUserUseCase @Inject constructor(
|
|
206
|
-
private val userRepository: UserRepository
|
|
207
|
-
) {
|
|
208
|
-
suspend operator fun invoke(id: String): Result<User> = runCatching {
|
|
209
|
-
userRepository.getUser(id)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
class CreateUserUseCase @Inject constructor(
|
|
214
|
-
private val userRepository: UserRepository,
|
|
215
|
-
private val validator: UserValidator
|
|
216
|
-
) {
|
|
217
|
-
suspend operator fun invoke(request: CreateUserRequest): Result<User> {
|
|
218
|
-
// 유효성 검사
|
|
219
|
-
validator.validate(request).onFailure { return Result.failure(it) }
|
|
220
|
-
|
|
221
|
-
return runCatching {
|
|
222
|
-
userRepository.createUser(request)
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
### 4. Repository 패턴
|
|
229
|
-
|
|
230
|
-
```kotlin
|
|
231
|
-
// ✅ Repository Interface
|
|
232
|
-
interface UserRepository {
|
|
233
|
-
suspend fun getUsers(): List<User>
|
|
234
|
-
suspend fun getUser(id: String): User
|
|
235
|
-
suspend fun createUser(request: CreateUserRequest): User
|
|
236
|
-
suspend fun updateUser(id: String, request: UpdateUserRequest): User
|
|
237
|
-
suspend fun deleteUser(id: String)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ✅ Repository 구현
|
|
241
|
-
class UserRepositoryImpl @Inject constructor(
|
|
242
|
-
private val apiService: UserApiService,
|
|
243
|
-
private val userDao: UserDao,
|
|
244
|
-
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
|
245
|
-
) : UserRepository {
|
|
246
|
-
|
|
247
|
-
override suspend fun getUsers(): List<User> = withContext(dispatcher) {
|
|
248
|
-
try {
|
|
249
|
-
// API에서 데이터 가져오기
|
|
250
|
-
val response = apiService.getUsers()
|
|
251
|
-
val users = response.map { it.toDomain() }
|
|
252
|
-
|
|
253
|
-
// 로컬 캐시 업데이트
|
|
254
|
-
userDao.insertAll(users.map { it.toEntity() })
|
|
255
|
-
|
|
256
|
-
users
|
|
257
|
-
} catch (e: Exception) {
|
|
258
|
-
// 오프라인: 로컬 데이터 반환
|
|
259
|
-
userDao.getAll().map { it.toDomain() }
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
override suspend fun getUser(id: String): User = withContext(dispatcher) {
|
|
264
|
-
val response = apiService.getUser(id)
|
|
265
|
-
response.toDomain()
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
### 5. 에러 처리
|
|
271
|
-
|
|
272
|
-
```kotlin
|
|
273
|
-
// ✅ 커스텀 예외
|
|
274
|
-
sealed class AppException(message: String) : Exception(message) {
|
|
275
|
-
class NetworkException(message: String = "네트워크 연결을 확인해주세요") : AppException(message)
|
|
276
|
-
class UnauthorizedException(message: String = "로그인이 필요합니다") : AppException(message)
|
|
277
|
-
class NotFoundException(
|
|
278
|
-
val resource: String,
|
|
279
|
-
val id: String
|
|
280
|
-
) : AppException("${resource}을(를) 찾을 수 없습니다 (ID: $id)")
|
|
281
|
-
class ServerException(message: String) : AppException(message)
|
|
282
|
-
class ValidationException(message: String) : AppException(message)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ✅ Result 확장 함수
|
|
286
|
-
inline fun <T> Result<T>.onSuccess(action: (T) -> Unit): Result<T> {
|
|
287
|
-
getOrNull()?.let(action)
|
|
288
|
-
return this
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
inline fun <T> Result<T>.onFailure(action: (Throwable) -> Unit): Result<T> {
|
|
292
|
-
exceptionOrNull()?.let(action)
|
|
293
|
-
return this
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ✅ API 응답 처리
|
|
297
|
-
suspend fun <T> safeApiCall(
|
|
298
|
-
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
299
|
-
apiCall: suspend () -> T
|
|
300
|
-
): Result<T> = withContext(dispatcher) {
|
|
301
|
-
runCatching {
|
|
302
|
-
apiCall()
|
|
303
|
-
}.recoverCatching { throwable ->
|
|
304
|
-
when (throwable) {
|
|
305
|
-
is HttpException -> {
|
|
306
|
-
when (throwable.code()) {
|
|
307
|
-
401 -> throw AppException.UnauthorizedException()
|
|
308
|
-
404 -> throw AppException.NotFoundException("리소스", "unknown")
|
|
309
|
-
else -> throw AppException.ServerException("서버 오류: ${throwable.code()}")
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
is IOException -> throw AppException.NetworkException()
|
|
313
|
-
else -> throw throwable
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
### 6. Hilt 의존성 주입
|
|
320
|
-
|
|
321
|
-
```kotlin
|
|
322
|
-
// ✅ Module 정의
|
|
323
|
-
@Module
|
|
324
|
-
@InstallIn(SingletonComponent::class)
|
|
325
|
-
object NetworkModule {
|
|
326
|
-
|
|
327
|
-
@Provides
|
|
328
|
-
@Singleton
|
|
329
|
-
fun provideOkHttpClient(): OkHttpClient {
|
|
330
|
-
return OkHttpClient.Builder()
|
|
331
|
-
.addInterceptor(AuthInterceptor())
|
|
332
|
-
.addInterceptor(HttpLoggingInterceptor().apply {
|
|
333
|
-
level = HttpLoggingInterceptor.Level.BODY
|
|
334
|
-
})
|
|
335
|
-
.connectTimeout(30, TimeUnit.SECONDS)
|
|
336
|
-
.readTimeout(30, TimeUnit.SECONDS)
|
|
337
|
-
.build()
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
@Provides
|
|
341
|
-
@Singleton
|
|
342
|
-
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
|
343
|
-
return Retrofit.Builder()
|
|
344
|
-
.baseUrl(BuildConfig.API_BASE_URL)
|
|
345
|
-
.client(okHttpClient)
|
|
346
|
-
.addConverterFactory(GsonConverterFactory.create())
|
|
347
|
-
.build()
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
@Provides
|
|
351
|
-
@Singleton
|
|
352
|
-
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
|
353
|
-
return retrofit.create(UserApiService::class.java)
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
@Module
|
|
358
|
-
@InstallIn(SingletonComponent::class)
|
|
359
|
-
abstract class RepositoryModule {
|
|
360
|
-
|
|
361
|
-
@Binds
|
|
362
|
-
@Singleton
|
|
363
|
-
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
|
|
364
|
-
}
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
### 7. 테스트
|
|
368
|
-
|
|
369
|
-
```kotlin
|
|
370
|
-
// ✅ ViewModel 테스트
|
|
371
|
-
@OptIn(ExperimentalCoroutinesApi::class)
|
|
372
|
-
class UserListViewModelTest {
|
|
373
|
-
|
|
374
|
-
@get:Rule
|
|
375
|
-
val mainDispatcherRule = MainDispatcherRule()
|
|
376
|
-
|
|
377
|
-
private lateinit var viewModel: UserListViewModel
|
|
378
|
-
private lateinit var getUsersUseCase: GetUsersUseCase
|
|
379
|
-
private lateinit var fakeUserRepository: FakeUserRepository
|
|
380
|
-
|
|
381
|
-
@Before
|
|
382
|
-
fun setup() {
|
|
383
|
-
fakeUserRepository = FakeUserRepository()
|
|
384
|
-
getUsersUseCase = GetUsersUseCase(fakeUserRepository)
|
|
385
|
-
viewModel = UserListViewModel(getUsersUseCase, SavedStateHandle())
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
@Test
|
|
389
|
-
fun `loadUsers 성공시 Success 상태가 된다`() = runTest {
|
|
390
|
-
// Given
|
|
391
|
-
val expectedUsers = listOf(
|
|
392
|
-
User(id = "1", name = "테스트1", email = "test1@example.com"),
|
|
393
|
-
User(id = "2", name = "테스트2", email = "test2@example.com")
|
|
394
|
-
)
|
|
395
|
-
fakeUserRepository.setUsers(expectedUsers)
|
|
396
|
-
|
|
397
|
-
// When
|
|
398
|
-
viewModel.loadUsers()
|
|
399
|
-
|
|
400
|
-
// Then
|
|
401
|
-
val state = viewModel.uiState.value
|
|
402
|
-
assertThat(state).isInstanceOf(UserListUiState.Success::class.java)
|
|
403
|
-
assertThat((state as UserListUiState.Success).users).hasSize(2)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
@Test
|
|
407
|
-
fun `loadUsers 실패시 Error 상태가 된다`() = runTest {
|
|
408
|
-
// Given
|
|
409
|
-
fakeUserRepository.setShouldReturnError(true)
|
|
410
|
-
|
|
411
|
-
// When
|
|
412
|
-
viewModel.loadUsers()
|
|
413
|
-
|
|
414
|
-
// Then
|
|
415
|
-
val state = viewModel.uiState.value
|
|
416
|
-
assertThat(state).isInstanceOf(UserListUiState.Error::class.java)
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// ✅ Fake Repository
|
|
421
|
-
class FakeUserRepository : UserRepository {
|
|
422
|
-
private var users = mutableListOf<User>()
|
|
423
|
-
private var shouldReturnError = false
|
|
424
|
-
|
|
425
|
-
fun setUsers(users: List<User>) {
|
|
426
|
-
this.users = users.toMutableList()
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
fun setShouldReturnError(value: Boolean) {
|
|
430
|
-
shouldReturnError = value
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
override suspend fun getUsers(): List<User> {
|
|
434
|
-
if (shouldReturnError) throw Exception("Test error")
|
|
435
|
-
return users
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// ... 다른 메서드
|
|
439
|
-
}
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
## 파일 구조
|
|
443
|
-
|
|
444
|
-
```
|
|
445
|
-
app/
|
|
446
|
-
├── src/main/java/com/example/app/
|
|
447
|
-
│ ├── di/ # Hilt 모듈
|
|
448
|
-
│ │ ├── NetworkModule.kt
|
|
449
|
-
│ │ └── RepositoryModule.kt
|
|
450
|
-
│ ├── data/
|
|
451
|
-
│ │ ├── api/ # API 서비스
|
|
452
|
-
│ │ │ └── UserApiService.kt
|
|
453
|
-
│ │ ├── local/ # Room DAO
|
|
454
|
-
│ │ │ └── UserDao.kt
|
|
455
|
-
│ │ ├── model/ # DTO
|
|
456
|
-
│ │ │ └── UserDto.kt
|
|
457
|
-
│ │ └── repository/ # Repository 구현
|
|
458
|
-
│ │ └── UserRepositoryImpl.kt
|
|
459
|
-
│ ├── domain/
|
|
460
|
-
│ │ ├── model/ # 도메인 모델
|
|
461
|
-
│ │ │ └── User.kt
|
|
462
|
-
│ │ ├── repository/ # Repository 인터페이스
|
|
463
|
-
│ │ │ └── UserRepository.kt
|
|
464
|
-
│ │ └── usecase/ # UseCase
|
|
465
|
-
│ │ └── GetUsersUseCase.kt
|
|
466
|
-
│ └── presentation/
|
|
467
|
-
│ ├── ui/
|
|
468
|
-
│ │ ├── components/ # 공통 Composable
|
|
469
|
-
│ │ └── theme/ # Material Theme
|
|
470
|
-
│ └── feature/
|
|
471
|
-
│ └── user/
|
|
472
|
-
│ ├── UserListScreen.kt
|
|
473
|
-
│ ├── UserListViewModel.kt
|
|
474
|
-
│ └── UserListUiState.kt
|
|
475
|
-
└── src/test/
|
|
476
|
-
└── java/com/example/app/
|
|
477
|
-
└── presentation/
|
|
478
|
-
└── feature/user/
|
|
479
|
-
└── UserListViewModelTest.kt
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
## 체크리스트
|
|
483
|
-
|
|
484
|
-
- [ ] Jetpack Compose 사용 (XML 레이아웃 지양)
|
|
485
|
-
- [ ] StateFlow로 UI 상태 관리
|
|
486
|
-
- [ ] Sealed Interface로 UiState 정의
|
|
487
|
-
- [ ] Hilt로 의존성 주입
|
|
488
|
-
- [ ] UseCase로 비즈니스 로직 분리
|
|
489
|
-
- [ ] Repository 패턴으로 데이터 계층 추상화
|
|
490
|
-
- [ ] Result/runCatching으로 에러 처리
|
|
491
|
-
- [ ] collectAsStateWithLifecycle() 사용
|
|
1
|
+
# 🤖 Kotlin + Android 품질 규칙
|
|
2
|
+
|
|
3
|
+
## 핵심 원칙 (core에서 상속)
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
✅ 단일 책임 (SRP)
|
|
7
|
+
✅ 중복 제거 (DRY)
|
|
8
|
+
✅ 재사용성
|
|
9
|
+
✅ 낮은 복잡도
|
|
10
|
+
✅ 함수 ≤ 30줄
|
|
11
|
+
✅ 중첩 ≤ 3단계
|
|
12
|
+
✅ Cyclomatic complexity ≤ 10
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Kotlin/Android 특화 규칙
|
|
16
|
+
|
|
17
|
+
### 1. Jetpack Compose UI
|
|
18
|
+
|
|
19
|
+
```kotlin
|
|
20
|
+
// ✅ Composable 함수
|
|
21
|
+
@Composable
|
|
22
|
+
fun UserProfileScreen(
|
|
23
|
+
viewModel: UserProfileViewModel = hiltViewModel(),
|
|
24
|
+
onNavigateBack: () -> Unit
|
|
25
|
+
) {
|
|
26
|
+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
27
|
+
|
|
28
|
+
UserProfileContent(
|
|
29
|
+
uiState = uiState,
|
|
30
|
+
onRefresh = viewModel::loadUser,
|
|
31
|
+
onNavigateBack = onNavigateBack
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ✅ Stateless Composable (재사용 가능)
|
|
36
|
+
@Composable
|
|
37
|
+
private fun UserProfileContent(
|
|
38
|
+
uiState: UserProfileUiState,
|
|
39
|
+
onRefresh: () -> Unit,
|
|
40
|
+
onNavigateBack: () -> Unit,
|
|
41
|
+
modifier: Modifier = Modifier
|
|
42
|
+
) {
|
|
43
|
+
Scaffold(
|
|
44
|
+
topBar = {
|
|
45
|
+
TopAppBar(
|
|
46
|
+
title = { Text("프로필") },
|
|
47
|
+
navigationIcon = {
|
|
48
|
+
IconButton(onClick = onNavigateBack) {
|
|
49
|
+
Icon(Icons.Default.ArrowBack, contentDescription = "뒤로")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
) { paddingValues ->
|
|
55
|
+
when (uiState) {
|
|
56
|
+
is UserProfileUiState.Loading -> LoadingContent(modifier.padding(paddingValues))
|
|
57
|
+
is UserProfileUiState.Success -> UserContent(
|
|
58
|
+
user = uiState.user,
|
|
59
|
+
modifier = modifier.padding(paddingValues)
|
|
60
|
+
)
|
|
61
|
+
is UserProfileUiState.Error -> ErrorContent(
|
|
62
|
+
message = uiState.message,
|
|
63
|
+
onRetry = onRefresh,
|
|
64
|
+
modifier = modifier.padding(paddingValues)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ✅ 재사용 가능한 컴포넌트
|
|
71
|
+
@Composable
|
|
72
|
+
fun UserCard(
|
|
73
|
+
user: User,
|
|
74
|
+
onClick: () -> Unit,
|
|
75
|
+
modifier: Modifier = Modifier
|
|
76
|
+
) {
|
|
77
|
+
Card(
|
|
78
|
+
modifier = modifier
|
|
79
|
+
.fillMaxWidth()
|
|
80
|
+
.clickable(onClick = onClick),
|
|
81
|
+
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
|
82
|
+
) {
|
|
83
|
+
Row(
|
|
84
|
+
modifier = Modifier.padding(16.dp),
|
|
85
|
+
verticalAlignment = Alignment.CenterVertically
|
|
86
|
+
) {
|
|
87
|
+
AsyncImage(
|
|
88
|
+
model = user.profileImage,
|
|
89
|
+
contentDescription = "${user.name} 프로필",
|
|
90
|
+
modifier = Modifier
|
|
91
|
+
.size(48.dp)
|
|
92
|
+
.clip(CircleShape)
|
|
93
|
+
)
|
|
94
|
+
Spacer(modifier = Modifier.width(16.dp))
|
|
95
|
+
Column {
|
|
96
|
+
Text(
|
|
97
|
+
text = user.name,
|
|
98
|
+
style = MaterialTheme.typography.titleMedium
|
|
99
|
+
)
|
|
100
|
+
Text(
|
|
101
|
+
text = user.email,
|
|
102
|
+
style = MaterialTheme.typography.bodySmall,
|
|
103
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 2. ViewModel (MVVM)
|
|
112
|
+
|
|
113
|
+
```kotlin
|
|
114
|
+
// ✅ UiState 정의 (Sealed Interface)
|
|
115
|
+
sealed interface UserListUiState {
|
|
116
|
+
data object Loading : UserListUiState
|
|
117
|
+
data class Success(
|
|
118
|
+
val users: List<User>,
|
|
119
|
+
val isRefreshing: Boolean = false
|
|
120
|
+
) : UserListUiState
|
|
121
|
+
data class Error(val message: String) : UserListUiState
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ✅ ViewModel with Hilt
|
|
125
|
+
@HiltViewModel
|
|
126
|
+
class UserListViewModel @Inject constructor(
|
|
127
|
+
private val getUsersUseCase: GetUsersUseCase,
|
|
128
|
+
private val savedStateHandle: SavedStateHandle
|
|
129
|
+
) : ViewModel() {
|
|
130
|
+
|
|
131
|
+
private val _uiState = MutableStateFlow<UserListUiState>(UserListUiState.Loading)
|
|
132
|
+
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
|
|
133
|
+
|
|
134
|
+
private val searchQuery = savedStateHandle.getStateFlow("search", "")
|
|
135
|
+
|
|
136
|
+
val filteredUsers: StateFlow<List<User>> = combine(
|
|
137
|
+
_uiState,
|
|
138
|
+
searchQuery
|
|
139
|
+
) { state, query ->
|
|
140
|
+
when (state) {
|
|
141
|
+
is UserListUiState.Success -> {
|
|
142
|
+
if (query.isBlank()) state.users
|
|
143
|
+
else state.users.filter { it.name.contains(query, ignoreCase = true) }
|
|
144
|
+
}
|
|
145
|
+
else -> emptyList()
|
|
146
|
+
}
|
|
147
|
+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
148
|
+
|
|
149
|
+
init {
|
|
150
|
+
loadUsers()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fun loadUsers() {
|
|
154
|
+
viewModelScope.launch {
|
|
155
|
+
_uiState.value = UserListUiState.Loading
|
|
156
|
+
|
|
157
|
+
getUsersUseCase()
|
|
158
|
+
.onSuccess { users ->
|
|
159
|
+
_uiState.value = UserListUiState.Success(users)
|
|
160
|
+
}
|
|
161
|
+
.onFailure { error ->
|
|
162
|
+
_uiState.value = UserListUiState.Error(
|
|
163
|
+
error.message ?: "사용자 목록을 불러올 수 없습니다"
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fun updateSearchQuery(query: String) {
|
|
170
|
+
savedStateHandle["search"] = query
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fun refresh() {
|
|
174
|
+
viewModelScope.launch {
|
|
175
|
+
val currentState = _uiState.value
|
|
176
|
+
if (currentState is UserListUiState.Success) {
|
|
177
|
+
_uiState.value = currentState.copy(isRefreshing = true)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getUsersUseCase()
|
|
181
|
+
.onSuccess { users ->
|
|
182
|
+
_uiState.value = UserListUiState.Success(users, isRefreshing = false)
|
|
183
|
+
}
|
|
184
|
+
.onFailure { /* 에러 처리 */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 3. UseCase (Clean Architecture)
|
|
191
|
+
|
|
192
|
+
```kotlin
|
|
193
|
+
// ✅ UseCase 정의
|
|
194
|
+
class GetUsersUseCase @Inject constructor(
|
|
195
|
+
private val userRepository: UserRepository,
|
|
196
|
+
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
|
197
|
+
) {
|
|
198
|
+
suspend operator fun invoke(): Result<List<User>> = withContext(dispatcher) {
|
|
199
|
+
runCatching {
|
|
200
|
+
userRepository.getUsers()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
class GetUserUseCase @Inject constructor(
|
|
206
|
+
private val userRepository: UserRepository
|
|
207
|
+
) {
|
|
208
|
+
suspend operator fun invoke(id: String): Result<User> = runCatching {
|
|
209
|
+
userRepository.getUser(id)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
class CreateUserUseCase @Inject constructor(
|
|
214
|
+
private val userRepository: UserRepository,
|
|
215
|
+
private val validator: UserValidator
|
|
216
|
+
) {
|
|
217
|
+
suspend operator fun invoke(request: CreateUserRequest): Result<User> {
|
|
218
|
+
// 유효성 검사
|
|
219
|
+
validator.validate(request).onFailure { return Result.failure(it) }
|
|
220
|
+
|
|
221
|
+
return runCatching {
|
|
222
|
+
userRepository.createUser(request)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### 4. Repository 패턴
|
|
229
|
+
|
|
230
|
+
```kotlin
|
|
231
|
+
// ✅ Repository Interface
|
|
232
|
+
interface UserRepository {
|
|
233
|
+
suspend fun getUsers(): List<User>
|
|
234
|
+
suspend fun getUser(id: String): User
|
|
235
|
+
suspend fun createUser(request: CreateUserRequest): User
|
|
236
|
+
suspend fun updateUser(id: String, request: UpdateUserRequest): User
|
|
237
|
+
suspend fun deleteUser(id: String)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ✅ Repository 구현
|
|
241
|
+
class UserRepositoryImpl @Inject constructor(
|
|
242
|
+
private val apiService: UserApiService,
|
|
243
|
+
private val userDao: UserDao,
|
|
244
|
+
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
|
245
|
+
) : UserRepository {
|
|
246
|
+
|
|
247
|
+
override suspend fun getUsers(): List<User> = withContext(dispatcher) {
|
|
248
|
+
try {
|
|
249
|
+
// API에서 데이터 가져오기
|
|
250
|
+
val response = apiService.getUsers()
|
|
251
|
+
val users = response.map { it.toDomain() }
|
|
252
|
+
|
|
253
|
+
// 로컬 캐시 업데이트
|
|
254
|
+
userDao.insertAll(users.map { it.toEntity() })
|
|
255
|
+
|
|
256
|
+
users
|
|
257
|
+
} catch (e: Exception) {
|
|
258
|
+
// 오프라인: 로컬 데이터 반환
|
|
259
|
+
userDao.getAll().map { it.toDomain() }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override suspend fun getUser(id: String): User = withContext(dispatcher) {
|
|
264
|
+
val response = apiService.getUser(id)
|
|
265
|
+
response.toDomain()
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### 5. 에러 처리
|
|
271
|
+
|
|
272
|
+
```kotlin
|
|
273
|
+
// ✅ 커스텀 예외
|
|
274
|
+
sealed class AppException(message: String) : Exception(message) {
|
|
275
|
+
class NetworkException(message: String = "네트워크 연결을 확인해주세요") : AppException(message)
|
|
276
|
+
class UnauthorizedException(message: String = "로그인이 필요합니다") : AppException(message)
|
|
277
|
+
class NotFoundException(
|
|
278
|
+
val resource: String,
|
|
279
|
+
val id: String
|
|
280
|
+
) : AppException("${resource}을(를) 찾을 수 없습니다 (ID: $id)")
|
|
281
|
+
class ServerException(message: String) : AppException(message)
|
|
282
|
+
class ValidationException(message: String) : AppException(message)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ✅ Result 확장 함수
|
|
286
|
+
inline fun <T> Result<T>.onSuccess(action: (T) -> Unit): Result<T> {
|
|
287
|
+
getOrNull()?.let(action)
|
|
288
|
+
return this
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
inline fun <T> Result<T>.onFailure(action: (Throwable) -> Unit): Result<T> {
|
|
292
|
+
exceptionOrNull()?.let(action)
|
|
293
|
+
return this
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ✅ API 응답 처리
|
|
297
|
+
suspend fun <T> safeApiCall(
|
|
298
|
+
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
299
|
+
apiCall: suspend () -> T
|
|
300
|
+
): Result<T> = withContext(dispatcher) {
|
|
301
|
+
runCatching {
|
|
302
|
+
apiCall()
|
|
303
|
+
}.recoverCatching { throwable ->
|
|
304
|
+
when (throwable) {
|
|
305
|
+
is HttpException -> {
|
|
306
|
+
when (throwable.code()) {
|
|
307
|
+
401 -> throw AppException.UnauthorizedException()
|
|
308
|
+
404 -> throw AppException.NotFoundException("리소스", "unknown")
|
|
309
|
+
else -> throw AppException.ServerException("서버 오류: ${throwable.code()}")
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
is IOException -> throw AppException.NetworkException()
|
|
313
|
+
else -> throw throwable
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### 6. Hilt 의존성 주입
|
|
320
|
+
|
|
321
|
+
```kotlin
|
|
322
|
+
// ✅ Module 정의
|
|
323
|
+
@Module
|
|
324
|
+
@InstallIn(SingletonComponent::class)
|
|
325
|
+
object NetworkModule {
|
|
326
|
+
|
|
327
|
+
@Provides
|
|
328
|
+
@Singleton
|
|
329
|
+
fun provideOkHttpClient(): OkHttpClient {
|
|
330
|
+
return OkHttpClient.Builder()
|
|
331
|
+
.addInterceptor(AuthInterceptor())
|
|
332
|
+
.addInterceptor(HttpLoggingInterceptor().apply {
|
|
333
|
+
level = HttpLoggingInterceptor.Level.BODY
|
|
334
|
+
})
|
|
335
|
+
.connectTimeout(30, TimeUnit.SECONDS)
|
|
336
|
+
.readTimeout(30, TimeUnit.SECONDS)
|
|
337
|
+
.build()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@Provides
|
|
341
|
+
@Singleton
|
|
342
|
+
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
|
343
|
+
return Retrofit.Builder()
|
|
344
|
+
.baseUrl(BuildConfig.API_BASE_URL)
|
|
345
|
+
.client(okHttpClient)
|
|
346
|
+
.addConverterFactory(GsonConverterFactory.create())
|
|
347
|
+
.build()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
@Provides
|
|
351
|
+
@Singleton
|
|
352
|
+
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
|
353
|
+
return retrofit.create(UserApiService::class.java)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@Module
|
|
358
|
+
@InstallIn(SingletonComponent::class)
|
|
359
|
+
abstract class RepositoryModule {
|
|
360
|
+
|
|
361
|
+
@Binds
|
|
362
|
+
@Singleton
|
|
363
|
+
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### 7. 테스트
|
|
368
|
+
|
|
369
|
+
```kotlin
|
|
370
|
+
// ✅ ViewModel 테스트
|
|
371
|
+
@OptIn(ExperimentalCoroutinesApi::class)
|
|
372
|
+
class UserListViewModelTest {
|
|
373
|
+
|
|
374
|
+
@get:Rule
|
|
375
|
+
val mainDispatcherRule = MainDispatcherRule()
|
|
376
|
+
|
|
377
|
+
private lateinit var viewModel: UserListViewModel
|
|
378
|
+
private lateinit var getUsersUseCase: GetUsersUseCase
|
|
379
|
+
private lateinit var fakeUserRepository: FakeUserRepository
|
|
380
|
+
|
|
381
|
+
@Before
|
|
382
|
+
fun setup() {
|
|
383
|
+
fakeUserRepository = FakeUserRepository()
|
|
384
|
+
getUsersUseCase = GetUsersUseCase(fakeUserRepository)
|
|
385
|
+
viewModel = UserListViewModel(getUsersUseCase, SavedStateHandle())
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@Test
|
|
389
|
+
fun `loadUsers 성공시 Success 상태가 된다`() = runTest {
|
|
390
|
+
// Given
|
|
391
|
+
val expectedUsers = listOf(
|
|
392
|
+
User(id = "1", name = "테스트1", email = "test1@example.com"),
|
|
393
|
+
User(id = "2", name = "테스트2", email = "test2@example.com")
|
|
394
|
+
)
|
|
395
|
+
fakeUserRepository.setUsers(expectedUsers)
|
|
396
|
+
|
|
397
|
+
// When
|
|
398
|
+
viewModel.loadUsers()
|
|
399
|
+
|
|
400
|
+
// Then
|
|
401
|
+
val state = viewModel.uiState.value
|
|
402
|
+
assertThat(state).isInstanceOf(UserListUiState.Success::class.java)
|
|
403
|
+
assertThat((state as UserListUiState.Success).users).hasSize(2)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@Test
|
|
407
|
+
fun `loadUsers 실패시 Error 상태가 된다`() = runTest {
|
|
408
|
+
// Given
|
|
409
|
+
fakeUserRepository.setShouldReturnError(true)
|
|
410
|
+
|
|
411
|
+
// When
|
|
412
|
+
viewModel.loadUsers()
|
|
413
|
+
|
|
414
|
+
// Then
|
|
415
|
+
val state = viewModel.uiState.value
|
|
416
|
+
assertThat(state).isInstanceOf(UserListUiState.Error::class.java)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ✅ Fake Repository
|
|
421
|
+
class FakeUserRepository : UserRepository {
|
|
422
|
+
private var users = mutableListOf<User>()
|
|
423
|
+
private var shouldReturnError = false
|
|
424
|
+
|
|
425
|
+
fun setUsers(users: List<User>) {
|
|
426
|
+
this.users = users.toMutableList()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
fun setShouldReturnError(value: Boolean) {
|
|
430
|
+
shouldReturnError = value
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
override suspend fun getUsers(): List<User> {
|
|
434
|
+
if (shouldReturnError) throw Exception("Test error")
|
|
435
|
+
return users
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ... 다른 메서드
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## 파일 구조
|
|
443
|
+
|
|
444
|
+
```
|
|
445
|
+
app/
|
|
446
|
+
├── src/main/java/com/example/app/
|
|
447
|
+
│ ├── di/ # Hilt 모듈
|
|
448
|
+
│ │ ├── NetworkModule.kt
|
|
449
|
+
│ │ └── RepositoryModule.kt
|
|
450
|
+
│ ├── data/
|
|
451
|
+
│ │ ├── api/ # API 서비스
|
|
452
|
+
│ │ │ └── UserApiService.kt
|
|
453
|
+
│ │ ├── local/ # Room DAO
|
|
454
|
+
│ │ │ └── UserDao.kt
|
|
455
|
+
│ │ ├── model/ # DTO
|
|
456
|
+
│ │ │ └── UserDto.kt
|
|
457
|
+
│ │ └── repository/ # Repository 구현
|
|
458
|
+
│ │ └── UserRepositoryImpl.kt
|
|
459
|
+
│ ├── domain/
|
|
460
|
+
│ │ ├── model/ # 도메인 모델
|
|
461
|
+
│ │ │ └── User.kt
|
|
462
|
+
│ │ ├── repository/ # Repository 인터페이스
|
|
463
|
+
│ │ │ └── UserRepository.kt
|
|
464
|
+
│ │ └── usecase/ # UseCase
|
|
465
|
+
│ │ └── GetUsersUseCase.kt
|
|
466
|
+
│ └── presentation/
|
|
467
|
+
│ ├── ui/
|
|
468
|
+
│ │ ├── components/ # 공통 Composable
|
|
469
|
+
│ │ └── theme/ # Material Theme
|
|
470
|
+
│ └── feature/
|
|
471
|
+
│ └── user/
|
|
472
|
+
│ ├── UserListScreen.kt
|
|
473
|
+
│ ├── UserListViewModel.kt
|
|
474
|
+
│ └── UserListUiState.kt
|
|
475
|
+
└── src/test/
|
|
476
|
+
└── java/com/example/app/
|
|
477
|
+
└── presentation/
|
|
478
|
+
└── feature/user/
|
|
479
|
+
└── UserListViewModelTest.kt
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## 체크리스트
|
|
483
|
+
|
|
484
|
+
- [ ] Jetpack Compose 사용 (XML 레이아웃 지양)
|
|
485
|
+
- [ ] StateFlow로 UI 상태 관리
|
|
486
|
+
- [ ] Sealed Interface로 UiState 정의
|
|
487
|
+
- [ ] Hilt로 의존성 주입
|
|
488
|
+
- [ ] UseCase로 비즈니스 로직 분리
|
|
489
|
+
- [ ] Repository 패턴으로 데이터 계층 추상화
|
|
490
|
+
- [ ] Result/runCatching으로 에러 처리
|
|
491
|
+
- [ ] collectAsStateWithLifecycle() 사용
|