diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift index a3d63f0e..4c6191ab 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -1,9 +1,10 @@ // Copyright © 2026 Devault. All rights reserved -import DVDomain import Foundation import SwiftData +import DVDomain + /// Domain의 SecretQuery를 SwiftData의 FetchDescriptor로 바꾸는 타입 enum SecretFetchDescriptorBuilder { static func make(from query: SecretQuery) -> FetchDescriptor { @@ -15,6 +16,63 @@ 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 + 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 && + (!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 && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + 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 +98,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/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/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/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/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/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/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/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift b/Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift index dac1e7dc..8b9ad2a1 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.values.count { matches($0, query: query) } + } + public func patch(id: UUID, with patch: SecretPatch) async throws -> Secret { patchCount += 1 lastPatch = patch @@ -145,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/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..aa358511 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,11 @@ public struct MainFeature { case .createSecret(.delegate(.secretCreated(_))): state.createSecret = nil state.selectSecretType = nil - return .send(.sidebar(.setCreatingSecret(false))) + // `.merge`는 도착 순서를 보장하지 않아 테스트가 깨지기 쉬우므로 순차 실행한다. + return .concatenate( + .send(.sidebar(.setCreatingSecret(false))), + .send(.sidebar(.countsRefreshRequested)) + ) case .createSecret(.delegate(.cancelled)): state.createSecret = nil @@ -197,7 +210,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..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,9 +48,6 @@ public struct SecretListFeature { } } - /// Expired 탭에서 "예정" 섹션으로 함께 보여줄 최대 기간(일). - static let expiringSoonWindowDays = 30 - // MARK: - Action public enum Action: Equatable { @@ -87,6 +80,9 @@ public struct SecretListFeature { public enum Delegate: Equatable { case secretSelected(Secret.ID?) + /// 삭제·복구·영구삭제·프로젝트 연결로 Secret 집합이 바뀌었음을 부모에게 알린다. + /// 부모가 사이드바 개수를 다시 세는 근거가 된다. + case secretsChanged } public enum Alert: Equatable {} @@ -154,8 +150,13 @@ public struct SecretListFeature { case .didTapDeleteForever(let id): return mutationEffect(id: id) { try await secretClient.permanentlyDelete(id) } + // 두 효과는 서로 독립적이지만 `.merge`는 도착 순서를 보장하지 않아 테스트가 깨지기 쉽다. + // 부모 갱신을 먼저 흘려보내고 재조회를 잇는다 (`.send`는 즉시 끝나므로 지연은 없다). case .mutationResponse(.success): - return fetchSecretsEffect(query: state.query, debounced: false) + return .concatenate( + .send(.delegate(.secretsChanged)), + fetchSecretsEffect(query: state.query, debounced: false) + ) case .mutationResponse(.failure): state.alert = AlertState { @@ -167,6 +168,13 @@ public struct SecretListFeature { } return .none + // 프로젝트 연결로 프로젝트별 개수가 바뀌므로 목록 갱신과 함께 부모에게도 알린다. + case .destination(.presented(.addToProject(.delegate(.projectLinked)))): + return .concatenate( + .send(.delegate(.secretsChanged)), + fetchSecretsEffect(query: state.query, debounced: false) + ) + 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..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 @@ -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..fcc5ef8a 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)) } } @@ -205,6 +205,12 @@ extension SidebarView { } } + /// 개수는 로드 완료 후에만 실제 값을 갖는다. 로드 전·실패 시에는 nil을 넘겨 + /// 숫자 자리를 비운다 — 0으로 대체하면 "시크릿 없음"과 구분되지 않는다. + private func count(for filter: SidebarFilter) -> Int? { + store.counts?.count(for: filter) + } + 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..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) @@ -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..4d212c54 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,62 @@ 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 탭과 같은 window 계산을 공유한다. + return .expiringWindow(from: referenceDate) + case .deleted: + return .deleted + } + } +} 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")