From c818188b76d858240348b5a42b8bee6faad54fc9 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 8 Aug 2026 11:08:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#77]=20fix:=20secretTypeSelectView=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EB=84=88=EB=B9=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVDesign/Sources/Components/DVSecretType.swift | 4 ++++ Projects/Devault/Sources/DevaultApp.swift | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVSecretType.swift b/Projects/DVDesign/Sources/Components/DVSecretType.swift index d6038cf4..efdde92d 100644 --- a/Projects/DVDesign/Sources/Components/DVSecretType.swift +++ b/Projects/DVDesign/Sources/Components/DVSecretType.swift @@ -49,10 +49,14 @@ extension DVSecretType { } } + /// 그리드 열이 좁아져도 라벨이 줄바꿈되지 않도록 고정한다. + /// 줄바꿈을 허용하면 행 높이가 커지면서 그리드 전체 높이가 연쇄적으로 늘어난다. private var typeLabel: some View { Text(labelText) .dvFont(.headingLG) .foregroundStyle(Color.dv(.gray900)) .multilineTextAlignment(.center) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } diff --git a/Projects/Devault/Sources/DevaultApp.swift b/Projects/Devault/Sources/DevaultApp.swift index 76e676ad..16ae144f 100644 --- a/Projects/Devault/Sources/DevaultApp.swift +++ b/Projects/Devault/Sources/DevaultApp.swift @@ -12,10 +12,17 @@ struct DevaultApp: App { AppFeature() } ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + // minWidth: 사이드바(200) + SelectSecretType 그리드 요구 너비(약 700)를 담을 수 있는 하한. + // 이보다 좁아지면 그리드 열이 찌그러져 카드가 겹친다. + .frame( + minWidth: 920, + maxWidth: .infinity, + minHeight: 600, + maxHeight: .infinity + ) } .windowStyle(.hiddenTitleBar) - .defaultSize(width: 800, height: 600) + .defaultSize(width: 960, height: 700) WindowGroup("Settings", id: "settings") { Text("Settings") From 27d52581284d1ed66ee213885f683943f40012ac Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 8 Aug 2026 12:01:40 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#77]=20feat:=20sidebar=20=EC=8B=9C?= =?UTF-8?q?=ED=81=AC=EB=A6=BF=20=EA=B0=9C=EC=88=98=20fetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Secret/SecretFetchDescriptorBuilder.swift | 53 +++++++++++- .../Secret/SecretRepositoryImpl.swift | 22 ++++- .../Interface/SecretRepository.swift | 7 ++ .../Impl/Secret/FetchSecretUseCaseImpl.swift | 8 ++ .../Interface/Secret/FetchSecretUseCase.swift | 5 ++ .../Support/InMemorySecretRepository.swift | 8 ++ .../Sources/Dependencies/SidebarClient.swift | 20 ++++- .../Sources/Features/Main/MainFeature.swift | 18 +++- .../SecretList/SecretListFeature.swift | 18 +++- .../Features/Sidebar/Model/SecretCounts.swift | 35 ++++++++ .../Features/Sidebar/SidebarFeature.swift | 50 ++++++++++- .../Features/Sidebar/SidebarView.swift | 12 ++- .../Tests/Main/MainFeatureTests.swift | 62 ++++++++++++- .../SecretList/SecretListFeatureTests.swift | 3 + .../Tests/Sidebar/SidebarFeatureTests.swift | 86 +++++++++++++++++++ .../Dependencies/SidebarClient+Live.swift | 66 ++++++++++++++ 16 files changed, 456 insertions(+), 17 deletions(-) create mode 100644 Projects/DVPresentation/Sources/Features/Sidebar/Model/SecretCounts.swift diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift index a3d63f0e..2f6f61f0 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -1,9 +1,9 @@ // Copyright © 2026 Devault. All rights reserved -import DVDomain import Foundation import SwiftData + /// Domain의 SecretQuery를 SwiftData의 FetchDescriptor로 바꾸는 타입 enum SecretFetchDescriptorBuilder { static func make(from query: SecretQuery) -> FetchDescriptor { @@ -15,6 +15,51 @@ enum SecretFetchDescriptorBuilder { return descriptor } + /// 개수 집계 전용 descriptor. 정렬은 개수에 영향이 없으므로 생략한다. + /// + /// `fetch` 경로는 SwiftData predicate를 통과한 뒤 `InMemorySecretQueryFilter`가 만료 항목을 한 번 더 + /// 걸러내지만, 개수 집계는 엔티티를 메모리로 올리지 않으므로 그 규칙을 predicate에 직접 넣어야 + /// 목록에 보이는 개수와 수치가 일치한다. (규칙 원본: `InMemorySecretQueryFilter.matchesExpiry`) + /// + /// `searchText`는 반영하지 않는다 — 사이드바 카운트는 검색어와 무관한 전체 개수를 보여준다. + static func makeCountDescriptor( + from query: SecretQuery, + referenceDate: Date + ) -> FetchDescriptor { + var descriptor = FetchDescriptor( + predicate: countPredicate(from: query, referenceDate: referenceDate) + ) + descriptor.includePendingChanges = true + return descriptor + } + + /// `.all`/`.liked`만 만료 조건을 추가하고, 나머지는 목록용 predicate를 그대로 쓴다. + /// + /// 만료일이 없는 Secret은 "만료되지 않음"으로 취급해야 하는데, `#Predicate` 안에서는 강제 언래핑을 + /// 쓸 수 없다(SwiftData가 SQL로 번역하지 못해 fetch 시점에 실패한다). `?? .distantFuture`로 대체한다. + private static func countPredicate( + from query: SecretQuery, + referenceDate: Date + ) -> Predicate { + let neverExpires = Date.distantFuture + + switch query.collection { + case .all: + return #Predicate { secret in + secret.deletedAt == nil && + (secret.expiresAt ?? neverExpires) >= referenceDate + } + case .liked: + return #Predicate { secret in + secret.deletedAt == nil && + secret.liked && + (secret.expiresAt ?? neverExpires) >= referenceDate + } + case .expired, .deleted, .project: + return predicate(from: query) + } + } + private static func predicate(from query: SecretQuery) -> Predicate { let hasSecretType = query.secretType != nil let secretType = query.secretType?.rawValue ?? "" @@ -40,10 +85,12 @@ enum SecretFetchDescriptorBuilder { (!hasEnvironment || secret.environment == environment) } case let .expired(referenceDate): + // 만료일이 없으면 `.distantFuture`로 치환 — referenceDate보다 항상 크므로 자연히 제외된다. + // (강제 언래핑은 SwiftData가 SQL로 번역하지 못해 fetch 시점에 실패한다) + let neverExpires = Date.distantFuture return #Predicate { secret in secret.deletedAt == nil && - secret.expiresAt != nil && - secret.expiresAt! < referenceDate && + (secret.expiresAt ?? neverExpires) < referenceDate && (!hasSecretType || secret.secretType == secretType) && (!hasService || secret.service == service) && (!hasEnvironment || secret.environment == environment) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift index 66d123dc..71deadb2 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -1,9 +1,11 @@ // Copyright © 2026 Devault. All rights reserved -import DVDomain import Foundation import SwiftData +import DVCore +import DVDomain + @ModelActor public actor SecretRepositoryImpl: SecretRepository { public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { @@ -84,6 +86,24 @@ public actor SecretRepositoryImpl: SecretRepository { throw SecretRepositoryError.persistenceFailed } } + + /// 조건에 맞는 개수만 집계한다. 엔티티를 Domain으로 매핑하지 않으므로 + /// 손상된 레코드가 섞여 있어도 실패하지 않는다. + public func count(_ query: SecretQuery) async throws -> Int { + do { + let descriptor = SecretFetchDescriptorBuilder.makeCountDescriptor( + from: query, + referenceDate: .now + ) + return try modelContext.fetchCount(descriptor) + } catch { + Log.error( + "[SecretRepository] count 실패 — collection: \(query.collection), error: \(error)", + category: .data + ) + throw SecretRepositoryError.persistenceFailed + } + } /// SecretPatch 적용하여 update public func patch(id: UUID, with patch: SecretPatch) async throws -> DVDomain.Secret { diff --git a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift index dbf84b7f..9db7f7bd 100644 --- a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -18,6 +18,13 @@ public protocol SecretRepository: Sendable { /// - Returns: 조건에 부합하는 Secret 배열 func fetch(_ query: SecretQuery) async throws -> [Secret] + /// 쿼리 조건에 맞는 Secret의 개수만 조회한다. + /// 엔티티를 메모리로 올리지 않으므로 `fetch(_:).count`보다 가볍고, + /// 손상된 레코드가 섞여 있어도 개수 집계는 실패하지 않는다. + /// - Parameter query: 필터 조건을 담은 SecretQuery. `sort`는 무시된다 + /// - Returns: 조건에 부합하는 Secret 개수 + func count(_ query: SecretQuery) async throws -> Int + /// Secret의 지정 필드를 수정한다. /// - Parameters: /// - id: 수정할 Secret의 ID diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift index b74f5d0b..c39f9d1b 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -33,6 +33,14 @@ public struct FetchSecretUseCaseImpl: FetchSecretUseCase { } } + public func count(query: SecretQuery) async throws -> Int { + do { + return try await repository.count(query) + } catch { + throw SecretUseCaseError.map(error) + } + } + public func fetchProjects(secretID: UUID) async throws -> [Project] { do { return try await repository.fetchProjects(secretID: secretID) diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift index 9af62c71..1d33e5ea 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -13,6 +13,11 @@ public protocol FetchSecretUseCase: Sendable { /// - Returns: 조건에 부합하는 Secret 배열 func fetch(query: SecretQuery) async throws -> [Secret] + /// 쿼리 조건에 맞는 Secret의 개수만 조회한다. 사이드바 카운트처럼 목록 본문이 필요 없을 때 쓴다. + /// - Parameter query: 필터 조건을 담은 SecretQuery. `sort`는 무시된다 + /// - Returns: 조건에 부합하는 Secret 개수 + func count(query: SecretQuery) async throws -> Int + /// Secret에 연결된 Project 목록을 조회한다. /// - Parameter secretID: 조회할 Secret의 ID /// - Returns: 해당 Secret에 연결된 Project 배열 diff --git a/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift b/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift index dac1e7dc..677640c1 100644 --- a/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift +++ b/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift @@ -17,6 +17,7 @@ public final class InMemorySecretRepository: SecretRepository, @unchecked Sendab public var errorOnCreate: SecretRepositoryError? public var errorOnFetchByID: SecretRepositoryError? public var errorOnFetchQuery: SecretRepositoryError? + public var errorOnCountQuery: SecretRepositoryError? public var errorOnPatch: SecretRepositoryError? public var errorOnDelete: SecretRepositoryError? public var errorOnFetchProjects: SecretRepositoryError? @@ -28,6 +29,7 @@ public final class InMemorySecretRepository: SecretRepository, @unchecked Sendab public private(set) var createCount = 0 public private(set) var fetchByIDCount = 0 public private(set) var fetchQueryCount = 0 + public private(set) var countQueryCount = 0 public private(set) var patchCount = 0 public private(set) var deleteCount = 0 public private(set) var fetchProjectsCount = 0 @@ -68,6 +70,12 @@ public final class InMemorySecretRepository: SecretRepository, @unchecked Sendab return Array(secrets.values) } + public func count(_ query: SecretQuery) async throws -> Int { + countQueryCount += 1 + if let error = errorOnCountQuery { throw error } + return secrets.count + } + public func patch(id: UUID, with patch: SecretPatch) async throws -> Secret { patchCount += 1 lastPatch = patch diff --git a/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift b/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift index 929f0ce4..3cd3371c 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift @@ -1,14 +1,24 @@ // Copyright © 2026 Devault. All rights reserved -import ComposableArchitecture import Foundation +import ComposableArchitecture + @DependencyClient public struct SidebarClient: Sendable { public var fetchProjects: @Sendable () async throws -> [ProjectItem] public var createProject: @Sendable (_ name: String) async throws -> ProjectItem public var renameProject: @Sendable (_ id: ProjectItem.ID, _ name: String) async throws -> ProjectItem public var deleteProject: @Sendable (_ id: ProjectItem.ID) async throws -> Void + + /// 필터 카드·프로젝트 행에 표시할 Secret 개수를 한 번에 조회한다. + /// - Parameters: + /// - referenceDate: Expired 집계 기준 시각. 호출부(Reducer)가 `@Dependency(\.date.now)`로 주입한다 + /// - projectIDs: 개수를 집계할 프로젝트 ID 목록 + public var fetchCounts: @Sendable ( + _ referenceDate: Date, + _ projectIDs: [ProjectItem.ID] + ) async throws -> SecretCounts } extension SidebarClient: TestDependencyKey { @@ -18,7 +28,13 @@ extension SidebarClient: TestDependencyKey { fetchProjects: { ProjectItem.previews }, createProject: { name in ProjectItem(id: UUID(), name: name) }, renameProject: { id, name in ProjectItem(id: id, name: name) }, - deleteProject: { _ in } + deleteProject: { _ in }, + fetchCounts: { _, projectIDs in + SecretCounts( + byFilter: [.all: 12, .starred: 3, .notice: 2, .expired: 1, .deleted: 4], + byProject: Dictionary(uniqueKeysWithValues: projectIDs.enumerated().map { ($1, $0 + 1) }) + ) + } ) } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index bb5ec443..a21bd241 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -1,8 +1,9 @@ // Copyright © 2026 Devault. All rights reserved -import ComposableArchitecture import SwiftUI +import ComposableArchitecture + // MARK: - MainFeature @Reducer @@ -51,6 +52,10 @@ public struct MainFeature { public enum Delegate: Equatable {} } + // MARK: - Dependencies + + @Dependency(\.date.now) var now + // MARK: - Init public init() {} @@ -87,6 +92,10 @@ public struct MainFeature { } return .none + // 자식끼리 직접 연결하지 않고 공통 부모가 사이드바 개수 갱신을 지시한다 (TCA_GUIDELINES 7.4). + case .secretList(.delegate(.secretsChanged)): + return .send(.sidebar(.countsRefreshRequested)) + case .secretList: return .none @@ -111,7 +120,10 @@ public struct MainFeature { case .createSecret(.delegate(.secretCreated(_))): state.createSecret = nil state.selectSecretType = nil - return .send(.sidebar(.setCreatingSecret(false))) + return .merge( + .send(.sidebar(.setCreatingSecret(false))), + .send(.sidebar(.countsRefreshRequested)) + ) case .createSecret(.delegate(.cancelled)): state.createSecret = nil @@ -197,7 +209,7 @@ extension MainFeature { // TODO: 도메인 레이어에 .notice collection 추가 후 연결 return .init(collection: .all) case .filter(.expired): - return .init(collection: .expired(referenceDate: Date())) + return .init(collection: .expired(referenceDate: now)) case .filter(.deleted): return .init(collection: .deleted) case .project(id: let id): diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 2027dbc6..4de995b9 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -53,7 +53,8 @@ public struct SecretListFeature { } /// Expired 탭에서 "예정" 섹션으로 함께 보여줄 최대 기간(일). - static let expiringSoonWindowDays = 30 + /// 사이드바 Expired 카운트도 같은 window를 써야 목록과 수치가 일치하므로 public이다. + public static let expiringSoonWindowDays = 30 // MARK: - Action @@ -87,6 +88,9 @@ public struct SecretListFeature { public enum Delegate: Equatable { case secretSelected(Secret.ID?) + /// 삭제·복구·영구삭제·프로젝트 연결로 Secret 집합이 바뀌었음을 부모에게 알린다. + /// 부모가 사이드바 개수를 다시 세는 근거가 된다. + case secretsChanged } public enum Alert: Equatable {} @@ -155,7 +159,10 @@ public struct SecretListFeature { return mutationEffect(id: id) { try await secretClient.permanentlyDelete(id) } case .mutationResponse(.success): - return fetchSecretsEffect(query: state.query, debounced: false) + return .merge( + fetchSecretsEffect(query: state.query, debounced: false), + .send(.delegate(.secretsChanged)) + ) case .mutationResponse(.failure): state.alert = AlertState { @@ -167,6 +174,13 @@ public struct SecretListFeature { } return .none + // 프로젝트 연결로 프로젝트별 개수가 바뀌므로 목록 갱신과 함께 부모에게도 알린다. + case .destination(.presented(.addToProject(.delegate(.projectLinked)))): + return .merge( + fetchSecretsEffect(query: state.query, debounced: false), + .send(.delegate(.secretsChanged)) + ) + case .destination: return .none diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/Model/SecretCounts.swift b/Projects/DVPresentation/Sources/Features/Sidebar/Model/SecretCounts.swift new file mode 100644 index 00000000..d4efc499 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Sidebar/Model/SecretCounts.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 사이드바에 표시할 Secret 개수 묶음. +/// 필터 카드 5종과 프로젝트 행 각각의 개수를 한 번의 조회로 함께 받아온다. +public struct SecretCounts: Equatable, Sendable { + + // MARK: - Properties + + public let byFilter: [SidebarFilter: Int] + public let byProject: [ProjectItem.ID: Int] + + // MARK: - Init + + public init( + byFilter: [SidebarFilter: Int] = [:], + byProject: [ProjectItem.ID: Int] = [:] + ) { + self.byFilter = byFilter + self.byProject = byProject + } + + // MARK: - Lookup + + /// 조회되지 않은 항목은 0으로 취급한다. + /// "아직 로드 전"과 "0건"의 구분은 `LoadingState`가 담당하므로 여기서는 값만 꺼낸다. + public func count(for filter: SidebarFilter) -> Int { + byFilter[filter] ?? 0 + } + + public func count(forProject id: ProjectItem.ID) -> Int { + byProject[id] ?? 0 + } +} diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 0b5ad262..4f76fb6f 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -54,6 +54,8 @@ public struct SidebarFeature { public internal(set) var isProjectSectionExpanded: Bool = true public internal(set) var isCreatingSecret: Bool = false var projectsState: LoadingState, SidebarError> = .idle + /// "아직 안 불러옴"과 "0건"을 구분하기 위해 LoadingState로 감싼다 (TCA_GUIDELINES 2.4). + var countsState: LoadingState = .idle var renamingProjectID: ProjectItem.ID? var renameText: String = "" var deletingProjectID: ProjectItem.ID? @@ -65,6 +67,12 @@ public struct SidebarFeature { return [] } + /// 로드 전·실패 시에는 nil. View가 "숫자 자리를 비울지" 판단할 수 있어야 하므로 옵셔널이다. + var counts: SecretCounts? { + if case .loaded(let counts) = countsState { return counts } + return nil + } + public init() {} } @@ -86,9 +94,13 @@ public struct SidebarFeature { case didTapDelete(id: ProjectItem.ID) case setCreatingSecret(Bool) + /// Secret이 생성·삭제·복구되어 개수만 다시 세야 할 때 부모(MainFeature)가 보낸다. + case countsRefreshRequested + // MARK: - Internal case projectsResponse(Result<[ProjectItem], SidebarError>) + case countsResponse(Result) case renameResponse(Result) case deleteResponse(Result) @@ -114,11 +126,15 @@ public struct SidebarFeature { // MARK: - CancelID (E5) - private enum CancelID { case fetch } + private enum CancelID { + case fetch + case counts + } // MARK: - Dependencies @Dependency(\.sidebarClient) var sidebarClient + @Dependency(\.date.now) var now // MARK: - Init @@ -131,6 +147,7 @@ public struct SidebarFeature { switch action { case .task: state.projectsState = .loading + state.countsState = .loading return .run { send in do { let projects = try await sidebarClient.fetchProjects() @@ -146,10 +163,24 @@ public struct SidebarFeature { case .projectsResponse(.success(let projects)): let sorted = projects.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } state.projectsState = .loaded(IdentifiedArray(uniqueElements: sorted)) - return .none + // 프로젝트별 개수를 세려면 ID 목록이 필요하므로 목록 로드 성공 후에 이어서 집계한다. + return countsEffect(projectIDs: sorted.map(\.id)) case .projectsResponse(.failure(let error)): state.projectsState = .failed(error) + // 프로젝트 목록이 실패해도 필터 카드 개수는 독립적으로 유효하므로 집계는 계속 시도한다. + return countsEffect(projectIDs: []) + + case .countsRefreshRequested: + state.countsState = .loading + return countsEffect(projectIDs: state.projects.map(\.id)) + + case .countsResponse(.success(let counts)): + state.countsState = .loaded(counts) + return .none + + case .countsResponse(.failure(let error)): + state.countsState = .failed(error) return .none case .didSelect(let selection): @@ -268,6 +299,21 @@ public struct SidebarFeature { private extension SidebarFeature { + /// 필터·프로젝트 개수 집계. 생성/삭제가 연달아 일어나면 직전 집계는 취소한다 (E3). + func countsEffect(projectIDs: [ProjectItem.ID]) -> Effect { + .run { [now] send in + do { + let counts = try await sidebarClient.fetchCounts(now, projectIDs) + await send(.countsResponse(.success(counts))) + } catch let error as SidebarError { + await send(.countsResponse(.failure(error))) + } catch { + await send(.countsResponse(.failure(.fetchFailed))) + } + } + .cancellable(id: CancelID.counts, cancelInFlight: true) + } + func makeDeleteAlert(for project: ProjectItem) -> AlertState { AlertState { TextState("'\(project.name)' 프로젝트를 삭제할까요?") diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index c11adbf2..a1698a5a 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -60,7 +60,7 @@ extension SidebarView { VStack(spacing: 12) { DVCategory( title: SidebarFilter.all.title, - count: 0, + count: count(for: .all), systemImage: SidebarFilter.all.icon, isSelected: !store.isCreatingSecret && store.selection == .filter(.all) ) { @@ -75,7 +75,7 @@ extension SidebarView { ForEach([SidebarFilter.starred, .notice, .expired, .deleted], id: \.self) { filter in DVCategory( title: filter.title, - count: 0, + count: count(for: filter), systemImage: filter.icon, iconColor: filter.iconColor, isSelected: !store.isCreatingSecret && store.selection == .filter(filter) @@ -189,7 +189,7 @@ extension SidebarView { onCancel: { store.send(.didCancelRename) } ) } else { - DVProjectContainer(name: project.name, count: 0) + DVProjectContainer(name: project.name, count: store.counts?.count(forProject: project.id) ?? 0) } } @@ -205,6 +205,12 @@ extension SidebarView { } } + /// 개수는 로드 완료 후에만 실제 값을 갖는다. 로드 전·실패 시에는 0으로 표시한다 + /// (State는 `countsState`로 "로드 전"과 "0건"을 구분해 들고 있다). + private func count(for filter: SidebarFilter) -> Int { + store.counts?.count(for: filter) ?? 0 + } + private func projectHeaderButton(icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index cd7363ca..a90328ed 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -11,6 +11,38 @@ import Testing @Suite("MainFeature") struct MainFeatureTests { + /// 사이드바 카운트 집계의 기준 시각을 고정한다. + static let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + + // MARK: - Count Refresh + + @Test("secretList의 secretsChanged 델리게이트는 사이드바 카운트를 다시 세게 한다") + func secretsChangedRefreshesSidebarCounts() async { + let item = ProjectItem(id: UUID(), name: "Backend") + let counts = SecretCounts(byFilter: [.all: 3], byProject: [item.id: 1]) + + var initial = MainFeature.State() + initial.sidebar.projectsState = .loaded([item]) + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchCounts = { _, projectIDs in + #expect(projectIDs == [item.id]) + return counts + } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.secretList(.delegate(.secretsChanged))) + await store.receive(.sidebar(.countsRefreshRequested)) { + $0.sidebar.countsState = .loading + } + await store.receive(.sidebar(.countsResponse(.success(counts)))) { + $0.sidebar.countsState = .loaded(counts) + } + } + // MARK: - Sidebar Delegate @Test("selectionChanged(.project)는 secretList를 해당 프로젝트로 갱신한다") @@ -89,9 +121,10 @@ struct MainFeatureTests { } } - @Test("secretCreated는 createSecret·selectSecretType을 닫고 isCreatingSecret을 false로 만든다") + @Test("secretCreated는 생성 플로우를 닫고 사이드바 카운트를 다시 세게 한다") func secretCreatedClearsCreationFlow() async { let secretID = UUID() + let counts = SecretCounts(byFilter: [.all: 1], byProject: [:]) var initial = MainFeature.State() initial.selectSecretType = .init() initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) @@ -99,6 +132,9 @@ struct MainFeatureTests { let store = TestStore(initialState: initial) { MainFeature() + } withDependencies: { + $0.sidebarClient.fetchCounts = { _, _ in counts } + $0.date = .constant(Self.referenceDate) } await store.send(.createSecret(.delegate(.secretCreated(secretID)))) { @@ -108,6 +144,12 @@ struct MainFeatureTests { await store.receive(.sidebar(.setCreatingSecret(false))) { $0.sidebar.isCreatingSecret = false } + await store.receive(.sidebar(.countsRefreshRequested)) { + $0.sidebar.countsState = .loading + } + await store.receive(.sidebar(.countsResponse(.success(counts)))) { + $0.sidebar.countsState = .loaded(counts) + } } @Test("cancelled는 createSecret을 닫고 selectSecretType을 초기화해 타입 선택으로 돌아간다") @@ -152,6 +194,8 @@ struct MainFeatureTests { MainFeature() } withDependencies: { $0.sidebarClient.fetchProjects = { [item] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) } await store.send(.createProject(.presented(.delegate(.projectCreated(item))))) { @@ -164,10 +208,14 @@ struct MainFeatureTests { } await store.receive(.sidebar(.task)) { $0.sidebar.projectsState = .loading + $0.sidebar.countsState = .loading } await store.receive(.sidebar(.projectsResponse(.success([item])))) { $0.sidebar.projectsState = .loaded([item]) } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } } @Test("projectCreated는 isCreatingSecret이 true일 때 selection을 변경하지 않는다") @@ -182,6 +230,8 @@ struct MainFeatureTests { MainFeature() } withDependencies: { $0.sidebarClient.fetchProjects = { [item] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) } await store.send(.createProject(.presented(.delegate(.projectCreated(item))))) { @@ -190,10 +240,14 @@ struct MainFeatureTests { } await store.receive(.sidebar(.task)) { $0.sidebar.projectsState = .loading + $0.sidebar.countsState = .loading } await store.receive(.sidebar(.projectsResponse(.success([item])))) { $0.sidebar.projectsState = .loaded([item]) } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } } // MARK: - SecretDetail Routing @@ -296,6 +350,8 @@ struct MainFeatureTests { MainFeature() } withDependencies: { $0.sidebarClient.fetchProjects = { [renamed] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) } await store.send(.sidebar(.renameResponse(.success(renamed)))) @@ -309,9 +365,13 @@ struct MainFeatureTests { // 이후 refetch로 목록도 동기화 await store.receive(.sidebar(.task)) { $0.sidebar.projectsState = .loading + $0.sidebar.countsState = .loading } await store.receive(.sidebar(.projectsResponse(.success([renamed])))) { $0.sidebar.projectsState = .loaded([renamed]) } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } } } diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index fe22c7ef..b46ce908 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -174,6 +174,7 @@ struct SecretListFeatureTests { await store.send(.didTapDelete(id: secret.id)) await store.receive(.mutationResponse(.success(secret.id))) + await store.receive(.delegate(.secretsChanged)) await store.receive(.secretsResponse(.success([]))) { $0.secretsState = .loaded([]) } @@ -212,6 +213,7 @@ struct SecretListFeatureTests { await store.send(.didTapRecover(id: secret.id)) await store.receive(.mutationResponse(.success(secret.id))) + await store.receive(.delegate(.secretsChanged)) await store.receive(.secretsResponse(.success([]))) { $0.secretsState = .loaded([]) } @@ -229,6 +231,7 @@ struct SecretListFeatureTests { await store.send(.didTapDeleteForever(id: secretID)) await store.receive(.mutationResponse(.success(secretID))) + await store.receive(.delegate(.secretsChanged)) await store.receive(.secretsResponse(.success([]))) { $0.secretsState = .loaded([]) } diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index 5eac5f14..39fa81c6 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -10,6 +10,9 @@ import Testing @Suite("SidebarFeature") struct SidebarFeatureTests { + /// Expired 카운트 기준 시각. `@Dependency(\.date.now)`를 고정해 집계 쿼리를 결정적으로 만든다. + static let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + // MARK: - Fetch @Test("task는 프로젝트 목록을 fetch해 이름 순으로 정렬한다") @@ -18,14 +21,18 @@ struct SidebarFeatureTests { ProjectItem(id: UUID(), name: "Mobile"), ProjectItem(id: UUID(), name: "Backend"), ] + let counts = SecretCounts(byFilter: [.all: 5], byProject: [:]) let store = TestStore(initialState: SidebarFeature.State()) { SidebarFeature() } withDependencies: { $0.sidebarClient.fetchProjects = { projects } + $0.sidebarClient.fetchCounts = { _, _ in counts } + $0.date = .constant(Self.referenceDate) } await store.send(.task) { $0.projectsState = .loading + $0.countsState = .loading } await store.receive(.projectsResponse(.success(projects))) { $0.projectsState = .loaded(IdentifiedArray(uniqueElements: [ @@ -33,22 +40,89 @@ struct SidebarFeatureTests { ProjectItem(id: projects[0].id, name: "Mobile"), ])) } + await store.receive(.countsResponse(.success(counts))) { + $0.countsState = .loaded(counts) + } } @Test("task fetch 실패 시 projectsState가 .failed로 전환된다") func taskFetchFailureSetsFailedState() async { + let counts = SecretCounts(byFilter: [.all: 5], byProject: [:]) let store = TestStore(initialState: SidebarFeature.State()) { SidebarFeature() } withDependencies: { $0.sidebarClient.fetchProjects = { throw SidebarError.fetchFailed } + $0.sidebarClient.fetchCounts = { _, _ in counts } + $0.date = .constant(Self.referenceDate) } await store.send(.task) { $0.projectsState = .loading + $0.countsState = .loading } await store.receive(.projectsResponse(.failure(.fetchFailed))) { $0.projectsState = .failed(.fetchFailed) } + // 프로젝트 목록이 실패해도 필터 카드 개수는 독립적으로 집계된다. + await store.receive(.countsResponse(.success(counts))) { + $0.countsState = .loaded(counts) + } + } + + // MARK: - Counts + + @Test("초기 countsState는 idle이라 '로드 전'과 '0건'이 구분된다") + func initialCountsStateIsIdle() { + let state = SidebarFeature.State() + + #expect(state.countsState == .idle) + #expect(state.counts == nil) + } + + @Test("countsRefreshRequested는 현재 프로젝트 ID로 개수를 다시 집계한다") + func countsRefreshRequestedRecountsWithCurrentProjects() async { + let item = ProjectItem(id: UUID(), name: "Backend") + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + + let counts = SecretCounts(byFilter: [.all: 7], byProject: [item.id: 2]) + let store = TestStore(initialState: state) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.fetchCounts = { date, projectIDs in + #expect(date == Self.referenceDate) + #expect(projectIDs == [item.id]) + return counts + } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.countsRefreshRequested) { + $0.countsState = .loading + } + await store.receive(.countsResponse(.success(counts))) { + $0.countsState = .loaded(counts) + #expect($0.counts?.count(for: .all) == 7) + #expect($0.counts?.count(forProject: item.id) == 2) + } + } + + @Test("카운트 집계 실패 시 countsState가 .failed로 전환된다") + func countsFailureSetsFailedState() async { + let store = TestStore(initialState: SidebarFeature.State()) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.fetchCounts = { _, _ in throw SidebarError.fetchFailed } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.countsRefreshRequested) { + $0.countsState = .loading + } + await store.receive(.countsResponse(.failure(.fetchFailed))) { + $0.countsState = .failed(.fetchFailed) + #expect($0.counts == nil) + } } // MARK: - Selection @@ -101,6 +175,8 @@ struct SidebarFeatureTests { } withDependencies: { $0.sidebarClient.renameProject = { _, _ in renamed } $0.sidebarClient.fetchProjects = { [renamed] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) } await store.send(.didConfirmRename) { @@ -111,10 +187,14 @@ struct SidebarFeatureTests { await store.receive(.delegate(.projectRenamed(renamed))) await store.receive(.task) { $0.projectsState = .loading + $0.countsState = .loading } await store.receive(.projectsResponse(.success([renamed]))) { $0.projectsState = .loaded([renamed]) } + await store.receive(.countsResponse(.success(SecretCounts()))) { + $0.countsState = .loaded(SecretCounts()) + } } @Test("didConfirmRename에서 nameTaken 오류 시 alert가 표시된다") @@ -209,6 +289,8 @@ struct SidebarFeatureTests { } withDependencies: { $0.sidebarClient.deleteProject = { _ in } $0.sidebarClient.fetchProjects = { [] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) } await store.send(.alert(.presented(.confirmDelete))) { @@ -221,9 +303,13 @@ struct SidebarFeatureTests { await store.receive(.delegate(.selectionChanged(.filter(.all)))) await store.receive(.task) { $0.projectsState = .loading + $0.countsState = .loading } await store.receive(.projectsResponse(.success([]))) { $0.projectsState = .loaded([]) } + await store.receive(.countsResponse(.success(SecretCounts()))) { + $0.countsState = .loaded(SecretCounts()) + } } } diff --git a/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift index 7a7d1fe1..1f48013e 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift @@ -1,5 +1,7 @@ // Copyright © 2026 Devault. All rights reserved +import Foundation + import ComposableArchitecture import DVData import DVDomain @@ -13,6 +15,11 @@ extension SidebarClient: @retroactive DependencyKey { let createUseCase: any CreateProjectUseCase = CreateProjectUseCaseImpl(repository: repo) let renameUseCase: any RenameProjectUseCase = RenameProjectUseCaseImpl(repository: repo) let deleteUseCase: any DeleteProjectUseCase = DeleteProjectUseCaseImpl(repository: repo) + let fetchSecretUseCase: any FetchSecretUseCase = FetchSecretUseCaseImpl( + repository: LiveRepositories.secret, + cryptoService: SecretCryptoServiceImpl(), + authenticationService: LocalUserAuthenticationServiceImpl() + ) return SidebarClient( fetchProjects: { @@ -48,7 +55,66 @@ extension SidebarClient: @retroactive DependencyKey { } catch { throw SidebarError.deleteFailed } + }, + fetchCounts: { referenceDate, projectIDs in + do { + return try await makeCounts( + useCase: fetchSecretUseCase, + referenceDate: referenceDate, + projectIDs: projectIDs + ) + } catch { + throw SidebarError.fetchFailed + } } ) }() } + +/// 필터 5종 + 프로젝트 N개의 개수를 집계한다. +/// 각 집계는 `fetchCount`로 개수만 세므로 엔티티를 메모리에 올리지 않는다. +private func makeCounts( + useCase: any FetchSecretUseCase, + referenceDate: Date, + projectIDs: [ProjectItem.ID] +) async throws -> SecretCounts { + var byFilter: [SidebarFilter: Int] = [:] + for filter in SidebarFilter.allCases { + byFilter[filter] = try await useCase.count( + query: SecretQuery(collection: filter.collection(referenceDate: referenceDate)) + ) + } + + var byProject: [ProjectItem.ID: Int] = [:] + for id in projectIDs { + byProject[id] = try await useCase.count(query: SecretQuery(collection: .project(id: id))) + } + + return SecretCounts(byFilter: byFilter, byProject: byProject) +} + +private extension SidebarFilter { + + /// 필터 카드 → 도메인 컬렉션 매핑. `MainFeature.makeSecretListState`와 같은 기준을 유지해야 + /// 카드에 찍힌 개수와 목록에 뜨는 개수가 어긋나지 않는다. + func collection(referenceDate: Date) -> SecretQuery.Collection { + switch self { + case .all: + return .all + case .starred: + return .liked + case .notice: + // TODO: 도메인 레이어에 .notice collection 추가 후 연결 (MainFeature도 동일하게 .all로 매핑 중) + return .all + case .expired: + // 목록의 Expired 탭은 "이미 지남 + N일 이내 예정"을 함께 보여주므로 같은 window를 적용한다. + return .expired( + referenceDate: referenceDate.addingTimeInterval( + TimeInterval(SecretListFeature.expiringSoonWindowDays) * 86_400 + ) + ) + case .deleted: + return .deleted + } + } +} From 8658a27689b1916d2e5bbbcc5b52e38f413aa33a Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 8 Aug 2026 12:03:36 +0900 Subject: [PATCH 3/6] [#77] feat: update SecretFetchDescriptionBuilder --- .../RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift index 2f6f61f0..5ce56645 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -3,6 +3,7 @@ import Foundation import SwiftData +import DVDomain /// Domain의 SecretQuery를 SwiftData의 FetchDescriptor로 바꾸는 타입 enum SecretFetchDescriptorBuilder { From a766063e6dbc7ba5fbb66dde219e05109efbbc91 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 8 Aug 2026 16:05:52 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[#77]=20fix:=20count=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EB=88=84=EB=9D=BD=20=EB=B0=8F=20effect?= =?UTF-8?q?=20=EB=8F=84=EC=B0=A9=20=EC=88=9C=EC=84=9C=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count 경로의 .all/.liked predicate가 secretType·service·environment를 무시해 fetch 결과와 계약이 갈리던 문제를 수정한다. 테스트 저장소의 count도 전체 개수 대신 같은 규칙으로 판정하도록 구현해, 필터별 카운트가 모두 같은 값이어도 테스트가 통과하던 구멍을 막는다. 목록 재조회와 부모 갱신 delegate를 .merge로 묶으면 도착 순서가 보장되지 않아 테스트가 깨지기 쉬우므로 .concatenate로 바꾼다. Co-Authored-By: Claude Sonnet 5 --- .../Secret/SecretFetchDescriptorBuilder.swift | 16 +++++- .../Support/InMemorySecretRepository.swift | 37 ++++++++++++- .../Secret/FetchSecretUseCaseImplTests.swift | 53 +++++++++++++++++++ .../Sources/Features/Main/MainFeature.swift | 3 +- .../SecretList/SecretListFeature.swift | 14 ++--- 5 files changed, 113 insertions(+), 10 deletions(-) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift index 5ce56645..4c6191ab 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -43,18 +43,30 @@ enum SecretFetchDescriptorBuilder { referenceDate: Date ) -> Predicate { let neverExpires = Date.distantFuture + let hasSecretType = query.secretType != nil + let secretType = query.secretType?.rawValue ?? "" + let hasService = !(query.service?.isEmpty ?? true) + let service = query.service ?? "" + let hasEnvironment = !(query.environment?.isEmpty ?? true) + let environment = query.environment ?? "" switch query.collection { case .all: return #Predicate { secret in secret.deletedAt == nil && - (secret.expiresAt ?? neverExpires) >= referenceDate + (secret.expiresAt ?? neverExpires) >= referenceDate && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) } case .liked: return #Predicate { secret in secret.deletedAt == nil && secret.liked && - (secret.expiresAt ?? neverExpires) >= referenceDate + (secret.expiresAt ?? neverExpires) >= referenceDate && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) } case .expired, .deleted, .project: return predicate(from: query) diff --git a/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift b/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift index 677640c1..8b9ad2a1 100644 --- a/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift +++ b/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift @@ -73,7 +73,7 @@ public final class InMemorySecretRepository: SecretRepository, @unchecked Sendab public func count(_ query: SecretQuery) async throws -> Int { countQueryCount += 1 if let error = errorOnCountQuery { throw error } - return secrets.count + return secrets.values.count { matches($0, query: query) } } public func patch(id: UUID, with patch: SecretPatch) async throws -> Secret { @@ -153,6 +153,41 @@ public final class InMemorySecretRepository: SecretRepository, @unchecked Sendab } } +// MARK: - Query 판정 + +private extension InMemorySecretRepository { + + /// `SecretRepositoryImpl.count(_:)`의 predicate와 같은 규칙으로 Secret 하나를 판정한다. + /// + /// 전체 개수를 그대로 돌려주면 필터별 카운트가 모두 같은 값이어도 테스트가 통과하므로, + /// 실제 저장소와 동일한 의미를 여기서도 구현한다. + /// `searchText`·`sort`는 실제 count 경로에서도 무시되므로 여기서도 보지 않는다. + func matches(_ secret: Secret, query: SecretQuery) -> Bool { + matchesCollection(secret, collection: query.collection) + && (query.secretType.map { secret.secretType == $0 } ?? true) + && (query.service.flatMap { $0.isEmpty ? nil : $0 }.map { secret.service == $0 } ?? true) + && (query.environment.flatMap { $0.isEmpty ? nil : $0 }.map { secret.environment == $0 } ?? true) + } + + func matchesCollection(_ secret: Secret, collection: SecretQuery.Collection) -> Bool { + // 만료일이 없으면 "만료되지 않음"으로 취급 — 실제 predicate의 `?? .distantFuture`와 같은 규칙. + let expiresAt = secret.expiresAt ?? .distantFuture + + switch collection { + case .all: + return secret.deletedAt == nil && expiresAt >= .now + case .liked: + return secret.deletedAt == nil && secret.liked && expiresAt >= .now + case let .expired(referenceDate): + return secret.deletedAt == nil && expiresAt < referenceDate + case .deleted: + return secret.deletedAt != nil + case let .project(projectID): + return secret.deletedAt == nil && (projectLinks[secret.id]?.contains(projectID) ?? false) + } + } +} + private extension Secret { mutating func apply(_ patch: SecretPatch) { if case let .set(name) = patch.name { self.name = name } diff --git a/Projects/DVDomain/Tests/Core/UseCase/Secret/FetchSecretUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Secret/FetchSecretUseCaseImplTests.swift index 2b783976..baa7e366 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Secret/FetchSecretUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Secret/FetchSecretUseCaseImplTests.swift @@ -44,6 +44,59 @@ struct FetchSecretUseCaseImplTests { #expect(result.count == 2) } + // MARK: - count(query:) + + @Test("count(query:)는 collection 조건에 맞는 개수만 센다") + func countAppliesCollection() async throws { + let repo = InMemorySecretRepository() + repo.seed(SecretFixture.make(id: UUID())) + repo.seed(SecretFixture.make(id: UUID(), liked: true)) + repo.seed(SecretFixture.make(id: UUID(), deletedAt: .now)) + let sut = makeSUT(repository: repo) + + #expect(try await sut.count(query: SecretQuery(collection: .all)) == 2) + #expect(try await sut.count(query: SecretQuery(collection: .liked)) == 1) + #expect(try await sut.count(query: SecretQuery(collection: .deleted)) == 1) + } + + @Test("count(query:)는 secretType·service·environment 필터를 함께 적용한다") + func countAppliesFieldFilters() async throws { + let repo = InMemorySecretRepository() + repo.seed(SecretFixture.make(id: UUID(), service: "github", environment: "prod")) + repo.seed(SecretFixture.make(id: UUID(), service: "github", environment: "dev")) + repo.seed(SecretFixture.make(id: UUID(), secretType: .database, service: "aws")) + let sut = makeSUT(repository: repo) + + #expect(try await sut.count(query: SecretQuery(service: "github")) == 2) + #expect(try await sut.count(query: SecretQuery(service: "github", environment: "prod")) == 1) + #expect(try await sut.count(query: SecretQuery(secretType: .database)) == 1) + } + + @Test("count(query:)는 만료된 Secret을 .all에서 제외하고 .expired에서만 센다") + func countSeparatesExpired() async throws { + let repo = InMemorySecretRepository() + let now = Date.now + repo.seed(SecretFixture.make(id: UUID(), expiresAt: now.addingTimeInterval(3600))) + repo.seed(SecretFixture.make(id: UUID(), expiresAt: now.addingTimeInterval(-3600))) + repo.seed(SecretFixture.make(id: UUID(), expiresAt: nil)) + let sut = makeSUT(repository: repo) + + // 만료일이 없는 Secret은 "만료되지 않음"으로 취급되어 .all에 포함된다. + #expect(try await sut.count(query: SecretQuery(collection: .all)) == 2) + #expect(try await sut.count(query: SecretQuery(collection: .expired(referenceDate: now))) == 1) + } + + @Test("count(query:)는 Repository 에러를 SecretUseCaseError로 매핑한다") + func countMapsRepositoryError() async { + let repo = InMemorySecretRepository() + repo.errorOnCountQuery = .persistenceFailed + let sut = makeSUT(repository: repo) + + await #expect(throws: SecretUseCaseError.repositoryFailure(.persistenceFailed)) { + _ = try await sut.count(query: SecretQuery()) + } + } + // MARK: - revealPayload @Test("revealPayload는 인증 후 fetch·decrypt를 호출한다") diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index a21bd241..aa358511 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -120,7 +120,8 @@ public struct MainFeature { case .createSecret(.delegate(.secretCreated(_))): state.createSecret = nil state.selectSecretType = nil - return .merge( + // `.merge`는 도착 순서를 보장하지 않아 테스트가 깨지기 쉬우므로 순차 실행한다. + return .concatenate( .send(.sidebar(.setCreatingSecret(false))), .send(.sidebar(.countsRefreshRequested)) ) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 4de995b9..a7871975 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -158,10 +158,12 @@ public struct SecretListFeature { case .didTapDeleteForever(let id): return mutationEffect(id: id) { try await secretClient.permanentlyDelete(id) } + // 두 효과는 서로 독립적이지만 `.merge`는 도착 순서를 보장하지 않아 테스트가 깨지기 쉽다. + // 부모 갱신을 먼저 흘려보내고 재조회를 잇는다 (`.send`는 즉시 끝나므로 지연은 없다). case .mutationResponse(.success): - return .merge( - fetchSecretsEffect(query: state.query, debounced: false), - .send(.delegate(.secretsChanged)) + return .concatenate( + .send(.delegate(.secretsChanged)), + fetchSecretsEffect(query: state.query, debounced: false) ) case .mutationResponse(.failure): @@ -176,9 +178,9 @@ public struct SecretListFeature { // 프로젝트 연결로 프로젝트별 개수가 바뀌므로 목록 갱신과 함께 부모에게도 알린다. case .destination(.presented(.addToProject(.delegate(.projectLinked)))): - return .merge( - fetchSecretsEffect(query: state.query, debounced: false), - .send(.delegate(.secretsChanged)) + return .concatenate( + .send(.delegate(.secretsChanged)), + fetchSecretsEffect(query: state.query, debounced: false) ) case .destination: From 21beb4f7b3562573cefd99fa10948b66bad4ba7f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:22:35 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#77]=20fix:=20SidebarFilter=20Sendable=20?= =?UTF-8?q?=EB=AF=B8=EC=A4=80=EC=88=98=20=EA=B2=BD=EA=B3=A0=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Sidebar/SidebarFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 4f76fb6f..1a4f29c5 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -6,7 +6,7 @@ import ComposableArchitecture // MARK: - SidebarFilter -public enum SidebarFilter: Equatable, CaseIterable, Hashable { +public enum SidebarFilter: Equatable, CaseIterable, Hashable, Sendable { case all case starred case notice From b2f3e817a2bea7afebb7f4043a7f940981ffa4fb Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 8 Aug 2026 18:31:49 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[#77]=20fix:=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9=C2=B7=EC=8B=A4=ED=8C=A8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=20window=20=EA=B3=84=EC=82=B0=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State에서 LoadingState로 "로드 전"과 "0건"을 구분해놓고 View에서 `?? 0`으로 되돌리고 있었다. 집계에 실패하면 에러 표시 없이 0이 남아 "시크릿 없음"으로 읽히므로, DVCategory·DVProjectContainer의 count를 옵셔널로 열고 nil이면 숫자 자리를 비운다. 만료 window(referenceDate + 30일) 계산이 목록과 사이드바 카운트에 각각 복붙돼 있어 한쪽만 바뀌면 조용히 어긋났다. SecretQuery.Collection에 expiringWindow(from:)을 두고 양쪽이 호출하게 한다. 상수를 위해 열어뒀던 SecretListFeature.expiringSoonWindowDays의 public도 걷어낸다. Co-Authored-By: Claude Sonnet 5 --- .../Sources/Components/DVCategory.swift | 15 ++++++---- .../Components/DVProjectContainer.swift | 16 +++++++---- .../Sources/Repository/SecretQuery.swift | 17 +++++++++++ .../Core/Repository/SecretQueryTests.swift | 28 +++++++++++++++++++ .../SecretList/SecretListFeature.swift | 12 ++------ .../Features/Sidebar/SidebarView.swift | 10 +++---- .../SecretList/SecretListFeatureTests.swift | 2 +- .../Dependencies/SidebarClient+Live.swift | 8 ++---- 8 files changed, 75 insertions(+), 33 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVCategory.swift b/Projects/DVDesign/Sources/Components/DVCategory.swift index 79bd0f67..9446d0fe 100644 --- a/Projects/DVDesign/Sources/Components/DVCategory.swift +++ b/Projects/DVDesign/Sources/Components/DVCategory.swift @@ -7,7 +7,9 @@ public struct DVCategory: View { // MARK: - Properties public let title: String - public let count: Int + /// nil이면 개수 라벨을 그리지 않는다 — 아직 집계 전이거나 집계에 실패한 상태. + /// 0으로 대체하면 "시크릿 없음"으로 읽히므로 자리를 비워 두는 쪽을 택한다. + public let count: Int? public let systemImage: String public let iconColor: Color public let isSelected: Bool @@ -19,7 +21,7 @@ public struct DVCategory: View { public init( title: String, - count: Int, + count: Int?, systemImage: String, iconColor: Color = Color.dv(.gray800), isSelected: Bool, @@ -78,10 +80,13 @@ extension DVCategory { .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray900)) } + @ViewBuilder private var countLabel: some View { - Text(count > 999 ? "999+" : "\(count)") - .dvFont(.bodyMD) - .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray600)) + if let count { + Text(count > 999 ? "999+" : "\(count)") + .dvFont(.bodyMD) + .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(.gray600)) + } } } diff --git a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift index 505123c8..1eaa8011 100644 --- a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift @@ -11,11 +11,12 @@ public struct DVProjectContainer: View { static let projectIconSystemName = "tray" public let name: String - public let count: Int + /// nil이면 개수 라벨을 그리지 않는다 (`DVCategory.count`와 같은 규칙). + public let count: Int? // MARK: - Init - public init(name: String, count: Int) { + public init(name: String, count: Int?) { self.name = name self.count = count } @@ -53,10 +54,13 @@ extension DVProjectContainer { .frame(minWidth: 40, alignment: .leading) } + @ViewBuilder private var countLabel: some View { - Text(count > 999 ? "999+" : "\(count)") - .dvFont(.bodyMD) - .foregroundStyle(Color.dv(.gray400)) - .fixedSize() + if let count { + Text(count > 999 ? "999+" : "\(count)") + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.gray400)) + .fixedSize() + } } } diff --git a/Projects/DVDomain/Sources/Repository/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/SecretQuery.swift index 7ee62c8e..675fe271 100644 --- a/Projects/DVDomain/Sources/Repository/SecretQuery.swift +++ b/Projects/DVDomain/Sources/Repository/SecretQuery.swift @@ -35,6 +35,23 @@ public extension SecretQuery { case expired(referenceDate: Date) case deleted case project(id: UUID) + + /// Expired 범위에 함께 담을 "만료 예정" 기간(일). + public static let expiringSoonWindowDays = 30 + + /// "이미 지남 + `expiringSoonWindowDays`일 이내 만료 예정"을 한 번에 담는 컬렉션. + /// + /// `expired` predicate는 `expiresAt < referenceDate` 단일 비교라, 기준일을 window만큼 + /// 미래로 밀어서 두 범위를 함께 가져온다. 목록 조회와 사이드바 개수 집계가 **같은 함수**를 + /// 써야 카드에 찍힌 숫자와 목록에 뜨는 개수가 어긋나지 않는다. + /// - Parameter referenceDate: 실제 "오늘". window를 적용하기 전의 기준 시각 + public static func expiringWindow(from referenceDate: Date) -> Self { + .expired( + referenceDate: referenceDate.addingTimeInterval( + TimeInterval(expiringSoonWindowDays) * 86_400 + ) + ) + } } enum Sort: Equatable, Sendable { diff --git a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift index 1be7538c..55c085c6 100644 --- a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift +++ b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift @@ -26,4 +26,32 @@ struct SecretQueryTests { #expect(a != b) } + + @Test("expiringWindow는 기준일을 expiringSoonWindowDays만큼 민 expired 컬렉션을 만든다") + func expiringWindowShiftsReferenceDate() { + let today = Date(timeIntervalSince1970: 0) + + let collection = SecretQuery.Collection.expiringWindow(from: today) + + let expected = today.addingTimeInterval( + TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) * 86_400 + ) + #expect(collection == .expired(referenceDate: expected)) + } + + @Test("expiringWindow는 이미 만료된 것과 window 이내 예정을 함께 담는다") + func expiringWindowCoversPastAndUpcoming() { + let today = Date(timeIntervalSince1970: 0) + let windowDays = TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) + + guard case let .expired(windowEnd) = SecretQuery.Collection.expiringWindow(from: today) else { + Issue.record("collection이 .expired가 아님") + return + } + + // predicate는 `expiresAt < windowEnd` 단일 비교이므로, 경계 안쪽만 포함되어야 한다. + #expect(today.addingTimeInterval(-86_400) < windowEnd) + #expect(today.addingTimeInterval((windowDays - 1) * 86_400) < windowEnd) + #expect(today.addingTimeInterval((windowDays + 1) * 86_400) > windowEnd) + } } diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index a7871975..b97b9bbe 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -31,18 +31,14 @@ public struct SecretListFeature { } /// `.expired`는 "이미 지남 + N일 이내 예정"을 한 화면에서 섹션으로 나눠 보여준다. - /// `SecretFetchDescriptorBuilder`의 `expired` predicate는 `expiresAt < referenceDate` 단일 비교라 - /// referenceDate를 `expiringSoonWindowDays`만큼 미래로 밀어서 두 범위를 한 번에 가져온다. + /// window 계산은 `SecretQuery.Collection.expiringWindow(from:)` 한 곳에만 두고 사이드바 개수 집계와 공유한다. /// 화면에 표시되는 `collection`(및 그 referenceDate)은 실제 "오늘"을 유지 — 섹션 분류 기준으로 View가 그대로 쓴다. var query: SecretQuery { let normalizedSearchText = searchText.isEmpty ? nil : searchText switch collection { case let .expired(referenceDate): - let windowEnd = referenceDate.addingTimeInterval( - TimeInterval(SecretListFeature.expiringSoonWindowDays) * 86_400 - ) return SecretQuery( - collection: .expired(referenceDate: windowEnd), + collection: .expiringWindow(from: referenceDate), searchText: normalizedSearchText, sort: .expiringSoon ) @@ -52,10 +48,6 @@ public struct SecretListFeature { } } - /// Expired 탭에서 "예정" 섹션으로 함께 보여줄 최대 기간(일). - /// 사이드바 Expired 카운트도 같은 window를 써야 목록과 수치가 일치하므로 public이다. - public static let expiringSoonWindowDays = 30 - // MARK: - Action public enum Action: Equatable { diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index a1698a5a..fcc5ef8a 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -189,7 +189,7 @@ extension SidebarView { onCancel: { store.send(.didCancelRename) } ) } else { - DVProjectContainer(name: project.name, count: store.counts?.count(forProject: project.id) ?? 0) + DVProjectContainer(name: project.name, count: store.counts?.count(forProject: project.id)) } } @@ -205,10 +205,10 @@ extension SidebarView { } } - /// 개수는 로드 완료 후에만 실제 값을 갖는다. 로드 전·실패 시에는 0으로 표시한다 - /// (State는 `countsState`로 "로드 전"과 "0건"을 구분해 들고 있다). - private func count(for filter: SidebarFilter) -> Int { - store.counts?.count(for: filter) ?? 0 + /// 개수는 로드 완료 후에만 실제 값을 갖는다. 로드 전·실패 시에는 nil을 넘겨 + /// 숫자 자리를 비운다 — 0으로 대체하면 "시크릿 없음"과 구분되지 않는다. + private func count(for filter: SidebarFilter) -> Int? { + store.counts?.count(for: filter) } private func projectHeaderButton(icon: String, action: @escaping () -> Void) -> some View { diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index b46ce908..9388d245 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -143,7 +143,7 @@ struct SecretListFeatureTests { return } let expectedWindowEnd = today.addingTimeInterval( - TimeInterval(SecretListFeature.expiringSoonWindowDays) * 86_400 + TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) * 86_400 ) #expect(windowEnd == expectedWindowEnd) #expect(query.sort == .expiringSoon) diff --git a/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift index 1f48013e..4d212c54 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift @@ -107,12 +107,8 @@ private extension SidebarFilter { // TODO: 도메인 레이어에 .notice collection 추가 후 연결 (MainFeature도 동일하게 .all로 매핑 중) return .all case .expired: - // 목록의 Expired 탭은 "이미 지남 + N일 이내 예정"을 함께 보여주므로 같은 window를 적용한다. - return .expired( - referenceDate: referenceDate.addingTimeInterval( - TimeInterval(SecretListFeature.expiringSoonWindowDays) * 86_400 - ) - ) + // 목록의 Expired 탭과 같은 window 계산을 공유한다. + return .expiringWindow(from: referenceDate) case .deleted: return .deleted }