Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Copyright © 2026 Devault. All rights reserved

import DVDomain
import Foundation
import SwiftData

import DVDomain

/// Domain의 SecretQuery를 SwiftData의 FetchDescriptor<SwiftDataModel.Secret>로 바꾸는 타입
enum SecretFetchDescriptorBuilder {
static func make(from query: SecretQuery) -> FetchDescriptor<SwiftDataModel.Secret> {
Expand All @@ -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<SwiftDataModel.Secret> {
var descriptor = FetchDescriptor<SwiftDataModel.Secret>(
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<SwiftDataModel.Secret> {
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<SwiftDataModel.Secret> { 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<SwiftDataModel.Secret> { 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)
Comment on lines +53 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

개수 조회에 모든 구조화 필터를 적용하세요.

Line 49와 Line 54의 predicate는 secretType, service, environment를 무시합니다. 반면 목록 조회는 같은 필터를 적용합니다. 따라서 count(_:) 결과가 fetch(_:) 결과와 달라집니다.

제공된 Projects/DVDomain/Tests/Core/Support/InMemorySecretRepository.swift:73-77도 쿼리를 무시하고 전체 secrets.count를 반환합니다. 테스트 저장소도 같은 쿼리 의미를 구현하세요.

  • Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift#L47-L60: .all.liked predicate에 SecretQuerysecretType, service, environment 조건을 추가하세요.
  • Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift#L21-L26: 모든 SecretRepository 구현체가 count(_:)에서 쿼리 조건을 적용하도록 계약 테스트를 추가하세요.
📍 Affects 2 files
  • Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift#L47-L60 (this comment)
  • Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift#L21-L26
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift`
around lines 47 - 60, Update SecretFetchDescriptorBuilder.swift lines 47-60 so
the .all and .liked predicates apply SecretQuery.secretType, service, and
environment filters consistently with fetch(_:) while preserving deletion,
expiration, and liked conditions. Update InMemorySecretRepository.swift lines
73-77 so count(_:) evaluates the supplied query instead of returning all
secrets. Add contract tests associated with SecretRepository.swift lines 21-26
to verify every SecretRepository implementation returns counts matching the
query filters.

}
}

private static func predicate(from query: SecretQuery) -> Predicate<SwiftDataModel.Secret> {
let hasSecretType = query.secretType != nil
let secretType = query.secretType?.rawValue ?? ""
Expand All @@ -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<SwiftDataModel.Secret> { 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 10 additions & 5 deletions Projects/DVDesign/Sources/Components/DVCategory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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))
}
}
}

Expand Down
16 changes: 10 additions & 6 deletions Projects/DVDesign/Sources/Components/DVProjectContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
}
}
}
4 changes: 4 additions & 0 deletions Projects/DVDesign/Sources/Components/DVSecretType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +52 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg 'DVSecretType|SelectSecretType|SecretType' || true

echo "== file sizes =="
for f in $(git ls-files | rg 'Projects/DVDesign/Sources/(Components/DVSecretType|.*SelectSecretType|.*SecretType).*\.swift' || true); do
  wc -l "$f"
done

echo "== DVSecretType outline and relevant contents =="
if [ -f Projects/DVDesign/Sources/Components/DVSecretType.swift ]; then
  ast-grep outline Projects/DVDesign/Sources/Components/DVSecretType.swift || true
  sed -n '1,140p' Projects/DVDesign/Sources/Components/DVSecretType.swift
fi

echo "== SelectSecretType relevant references =="
rg -n "SelectSecretTypeView|typeLabel|labelText|type\.displayName|secret type|SecretType" Projects/DVDesign/Sources -g '*.swift' -C 3 || true

Repository: DevaultProject/Devault-macOS

Length of output: 5611


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SelectSecretTypeView relevant contents =="
sed -n '1,180p' Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeView.swift

echo "== DVSecretType preview usage =="
sed -n '1,140p' Projects/DVDesign/SampleApp/Sources/DVSecretTypePreviewView.swift

echo "== SecretType definitions =="
for f in Projects/DVDomain/Sources/Entity/SecretType.swift Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift; do
  echo "--- $f"
  sed -n '1,220p' "$f"
done

echo "== grid modifiers / DVSecretType usages =="
rg -n "DVSecretType|AnyGridItem|CollectionGrid|LazyVGrid|horizontalSizeClass|Fixed|gridColumn|lineLimit|fixedSize|truncationMode|\.center" Projects/DVPresentation Projects/DVDesign -g '*.swift' -C 2 || true

echo "== deterministic SwiftUI modifier order behavior probe =="
cat > /tmp/swiftui_fixedsize_order_probe.swift <<'SWIFT'
struct TextExtensions {
    static func lineLimit(_ value: Int? = 0) {}
    static func fixedSize(horizontal: Bool = true, vertical: Bool = false) {}
    static func truncationMode(_ mode: String = "tail") {}
}
// This is a source-only compile probe, not the repository code.
// A Text would accept lineLimit → truncationMode → fixedSize;
// lineLimit without truncationMode and with fixedSize often preserves intrinsic label width.
SWIFT
swiftc --version 2>/dev/null || true

Repository: DevaultProject/Devault-macOS

Length of output: 25145


typeLabel의 가로 fixedSize를 줄바꿈 대신 truncation으로 처리하세요.

SelectSecretTypeViewLazyVGrid(columns: [...GridItem(.flexible())...])처럼 유연한 열 너비를 제공하지만, DVSecretTypetypeLabellineLimit(1) 위에 fixedSize(horizontal: true, vertical: false)를 적용해 부모 제안 너비를 무시합니다. 긴 현지화 라벨은 카드 너비를 벗어나고, 가로 고정과 truncationMode(.tail)은 동시에 적용할 수 있으므로 제거 방향을 고려하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DVDesign/Sources/Components/DVSecretType.swift` around lines 52 -
60, Update DVSecretType.typeLabel to respect the flexible grid column width by
removing the horizontal fixedSize behavior and applying tail truncation to the
single-line label. Preserve the existing styling, centered alignment, and
lineLimit(1).

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions Projects/DVDomain/Sources/Repository/SecretQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 배열
Expand Down
28 changes: 28 additions & 0 deletions Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Loading