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
2 changes: 1 addition & 1 deletion Projects/DVData/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ let project = Project.project(
),
.tests(
name: "DVDataTests",
sources: ["Tests/**"],
dependencies: [
.data(),
.domain(),
]
),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ enum InMemorySecretQueryFilter {
let filtered = secrets
.filter { matchesSearchText(query.searchText, secret: $0) }
.filter { matchesExpiry(query.collection, secret: $0, referenceDate: referenceDate) }
return sortedByNameIfNeeded(filtered, sort: query.sort)
return applySortIfNeeded(filtered, sort: query.sort)
}

/// `.all`/`.liked`(Star)는 이미 만료된 Secret을 보여주지 않는다 — 만료된 항목은 Expired 탭에서만 보인다.
Expand All @@ -38,22 +38,58 @@ enum InMemorySecretQueryFilter {
return expiresAt >= referenceDate
}

/// `Array.sorted`는 안정 정렬이라, SwiftData 단계의 updatedAt tie-break 순서가
/// 동률(같은 이름, 같은 만료일 없음)일 때 그대로 유지된다.
private static func applySortIfNeeded(
_ secrets: [DVDomain.Secret],
sort: SecretQuery.Sort
) -> [DVDomain.Secret] {
switch sort.key {
case .name:
return sortedByName(secrets, direction: sort.direction)
case .expiry:
return sortedByExpiry(secrets, direction: sort.direction)
case .time:
// SwiftData `SortDescriptor(\.updatedAt)`로 이미 원하는 순서로 왔다.
return secrets
}
}

/// SwiftData `SortDescriptor(\.name)`는 Unicode 코드포인트 순서(대소문자 구분)로만 비교해
/// 한국어·영어가 섞인 이름을 사람이 기대하는 순서로 정렬하지 못한다.
/// `localizedStandardCompare`로 대소문자 무시·로케일 인식 비교를 적용한다.
/// `Array.sorted`는 안정 정렬이라, SwiftData 단계의 updatedAt tie-break 순서는 이름이 같을 때 그대로 유지된다.
private static func sortedByNameIfNeeded(
private static func sortedByName(
_ secrets: [DVDomain.Secret],
sort: SecretQuery.Sort
direction: SecretQuery.Sort.Direction
) -> [DVDomain.Secret] {
switch sort {
case .nameAscending:
return secrets.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
case .nameDescending:
return secrets.sorted { $0.name.localizedStandardCompare($1.name) == .orderedDescending }
case .recentlyAdded, .oldestFirst, .expiringSoon:
return secrets
secrets.sorted {
let order = $0.name.localizedStandardCompare($1.name)
switch direction {
case .ascending: return order == .orderedAscending
case .descending: return order == .orderedDescending
}
}
}

/// SwiftData `SortDescriptor`는 옵셔널을 `nil < .some`으로만 비교해, 오름차순에서
/// 만료일 없는 Secret이 맨 앞에 온다. 방향과 무관하게 항상 뒤로 보내려면 SwiftData가
/// 만들어준 순서를 무시하고 여기서 다시 정렬해야 한다.
private static func sortedByExpiry(
_ secrets: [DVDomain.Secret],
direction: SecretQuery.Sort.Direction
) -> [DVDomain.Secret] {
let withExpiry = secrets.filter { $0.expiresAt != nil }
let withoutExpiry = secrets.filter { $0.expiresAt == nil }
let sorted = withExpiry.sorted { lhs, rhs in
guard let lhsExpiresAt = lhs.expiresAt, let rhsExpiresAt = rhs.expiresAt else {
return false
}
switch direction {
case .ascending: return lhsExpiresAt < rhsExpiresAt
case .descending: return lhsExpiresAt > rhsExpiresAt
}
}
return sorted + withoutExpiry
}

private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,33 +141,25 @@ enum SecretFetchDescriptorBuilder {
}
}

/// 만료일(`.expiry`) 정렬에서 `nil`이 방향과 무관하게 뒤로 가야 하는 요구사항은
/// SwiftData `SortDescriptor`(옵셔널을 `nil < .some`으로만 비교) 하나로는 표현할 수 없다.
/// 여기서는 SwiftData가 다룰 수 있는 만큼만 정렬하고, `InMemorySecretQueryFilter`가
/// nil을 뒤로 보내는 후처리를 맡는다.
private static func sortDescriptors(
from sort: SecretQuery.Sort
) -> [SortDescriptor<SwiftDataModel.Secret>] {
switch sort {
case .recentlyAdded:
let order: SortOrder = sort.direction == .ascending ? .forward : .reverse
switch sort.key {
case .time:
return [SortDescriptor(\.updatedAt, order: order)]
case .expiry:
return [
SortDescriptor(\.createdAt, order: .reverse),
SortDescriptor(\.expiresAt, order: order),
SortDescriptor(\.updatedAt, order: .reverse),
]
case .oldestFirst:
case .name:
return [
SortDescriptor(\.createdAt, order: .forward),
SortDescriptor(\.updatedAt, order: .forward),
]
case .expiringSoon:
return [
SortDescriptor(\.expiresAt, order: .forward),
SortDescriptor(\.updatedAt, order: .reverse),
]
case .nameAscending:
return [
SortDescriptor(\.name, order: .forward),
SortDescriptor(\.updatedAt, order: .reverse),
]
case .nameDescending:
return [
SortDescriptor(\.name, order: .reverse),
SortDescriptor(\.name, order: order),
SortDescriptor(\.updatedAt, order: .reverse),
]
}
Expand Down
125 changes: 125 additions & 0 deletions Projects/DVData/Tests/RepositoryImpl/Secret/SecretSortingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData
import Testing

import DVDomain

@testable import DVData

/// `SecretQuery.Sort`의 6개 조합(time/expiry/name × ascending/descending)이 실제 SwiftData
/// fetch 결과에 반영되는지 검증한다. `SecretFetchDescriptorBuilder`(SwiftData 레벨 정렬)와
/// `InMemorySecretQueryFilter`(name·expiry 후처리 정렬)를 합친 최종 결과를 본다 —
/// 사용자가 실제로 관찰하는 순서가 이 둘의 조합이기 때문이다.
@Suite("Secret 정렬")
struct SecretSortingTests {

// MARK: - time

@Test("time 오름차순은 updatedAt이 오래된 순")
func timeAscending() async throws {
let repository = try await makeRepository()
let old = try await repository.seed(name: "Old", updatedAt: .reference)
let new = try await repository.seed(name: "New", updatedAt: .reference.addingTimeInterval(3600))

let result = try await repository.fetch(SecretQuery(sort: .init(key: .time, direction: .ascending)))

#expect(result.map(\.id) == [old.id, new.id])
}

@Test("time 내림차순은 updatedAt이 최신인 순 — 기존 recentlyAdded와 동일하다")
func timeDescending() async throws {
let repository = try await makeRepository()
let old = try await repository.seed(name: "Old", updatedAt: .reference)
let new = try await repository.seed(name: "New", updatedAt: .reference.addingTimeInterval(3600))

let result = try await repository.fetch(SecretQuery(sort: .init(key: .time, direction: .descending)))

#expect(result.map(\.id) == [new.id, old.id])
}

// MARK: - expiry

@Test("expiry 오름차순은 만료가 가까운 순, 만료일 없는 항목은 맨 뒤로 간다")
func expiryAscending() async throws {
let repository = try await makeRepository()
let soon = try await repository.seed(name: "Soon", expiresAt: .reference.addingTimeInterval(86_400))
let later = try await repository.seed(name: "Later", expiresAt: .reference.addingTimeInterval(2 * 86_400))
let never = try await repository.seed(name: "Never", expiresAt: nil)

let result = try await repository.fetch(SecretQuery(sort: .init(key: .expiry, direction: .ascending)))

#expect(result.map(\.id) == [soon.id, later.id, never.id])
}

@Test("expiry 내림차순은 만료가 먼 순, 만료일 없는 항목은 여전히 맨 뒤로 간다")
func expiryDescending() async throws {
let repository = try await makeRepository()
let soon = try await repository.seed(name: "Soon", expiresAt: .reference.addingTimeInterval(86_400))
let later = try await repository.seed(name: "Later", expiresAt: .reference.addingTimeInterval(2 * 86_400))
let never = try await repository.seed(name: "Never", expiresAt: nil)

let result = try await repository.fetch(SecretQuery(sort: .init(key: .expiry, direction: .descending)))

#expect(result.map(\.id) == [later.id, soon.id, never.id])
}

// MARK: - name

@Test("name 오름차순은 로케일 인식 오름차순(A→Z)")
func nameAscending() async throws {
let repository = try await makeRepository()
let banana = try await repository.seed(name: "Banana")
let apple = try await repository.seed(name: "Apple")

let result = try await repository.fetch(SecretQuery(sort: .init(key: .name, direction: .ascending)))

#expect(result.map(\.id) == [apple.id, banana.id])
}

@Test("name 내림차순은 로케일 인식 내림차순(Z→A)")
func nameDescending() async throws {
let repository = try await makeRepository()
let banana = try await repository.seed(name: "Banana")
let apple = try await repository.seed(name: "Apple")

let result = try await repository.fetch(SecretQuery(sort: .init(key: .name, direction: .descending)))

#expect(result.map(\.id) == [banana.id, apple.id])
}

// MARK: - Helpers

private func makeRepository() async throws -> SecretRepositoryImpl {
let schema = Schema.appSchema
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: [configuration])
return SecretRepositoryImpl(modelContainer: container)
}
}

private extension Date {
static let reference = Date(timeIntervalSince1970: 1_800_000_000)
}

private extension SecretRepositoryImpl {
/// 정렬 검증에 필요한 필드만 지정하고 나머지는 고정값으로 채운 Secret을 저장한다.
@discardableResult
func seed(
name: String,
updatedAt: Date = .reference,
expiresAt: Date? = nil
) async throws -> DVDomain.Secret {
let secret = DVDomain.Secret(
id: UUID(),
name: name,
secretType: .apiKeyToken,
expiresAt: expiresAt,
createdAt: .reference,
updatedAt: updatedAt,
payload: DVDomain.SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1)
)
return try await create(secret)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,15 @@ struct DVTitleBarPreviewView: View {
DVTitleBar(
titleText: "All",
searchText: $searchText,
onSortTapped: {}
sortMenuContent: { AnyView(Text("Sort menu content")) }
)
.frame(width: 280)
.background(Color.white)
}
previewSection("Expired") {
previewSection("정렬 없음") {
DVTitleBar(
titleText: "Expired",
searchText: .constant(""),
onSortTapped: {}
searchText: .constant("")
)
.frame(width: 280)
.background(Color.white)
Expand Down
23 changes: 14 additions & 9 deletions Projects/DVDesign/Sources/Components/DVTitleBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@ public struct DVTitleBar: View {
public let titleText: String
public let searchText: Binding<String>
public let searchPromptText: String
public let onSortTapped: (() -> Void)?
/// 정렬 버튼을 누르면 펼쳐질 메뉴 내용. `nil`이면 정렬 버튼 자체를 그리지 않는다.
/// 클로저가 반환하는 뷰가 시스템 `Menu` 안에 들어가므로, 바깥 클릭·ESC·포커스 상실 처리는 시스템이 담당한다.
public let sortMenuContent: (() -> AnyView)?

// MARK: - Init

/// - Parameter onSortTapped: 정렬 버튼 탭 핸들러. `nil`이면 정렬 버튼 자체를 그리지 않는다.
public init(
titleText: String,
searchText: Binding<String>,
searchPromptText: String = "Search",
onSortTapped: (() -> Void)? = nil
sortMenuContent: (() -> AnyView)? = nil
) {
self.titleText = titleText
self.searchText = searchText
self.searchPromptText = searchPromptText
self.onSortTapped = onSortTapped
self.sortMenuContent = sortMenuContent
}

// MARK: - Body
Expand All @@ -47,20 +48,24 @@ extension DVTitleBar {
.dvFont(.headingXL)
.foregroundStyle(Color.dv(.gray900))
Spacer()
if let onSortTapped {
sortButton(action: onSortTapped)
if let sortMenuContent {
sortMenu(content: sortMenuContent)
}
}
.padding(.vertical, 4)
}

private func sortButton(action: @escaping () -> Void) -> some View {
Button(action: action) {
private func sortMenu(content: () -> AnyView) -> some View {
Menu {
content()
} label: {
Image(systemName: "arrow.up.arrow.down")
.dvFont(.bodyXL)
.foregroundStyle(Color.dv(.gray800))
}
.buttonStyle(.plain)
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
}

private var searchField: some View {
Expand Down
35 changes: 29 additions & 6 deletions Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,34 @@ extension SecretQuery {
}
}

public enum Sort: Equatable, Sendable {
case recentlyAdded
case oldestFirst
case expiringSoon
case nameAscending
case nameDescending
/// 정렬 기준(`key`)과 방향(`direction`)을 독립된 축으로 표현한다.
///
/// 이전엔 `recentlyAdded`/`oldestFirst`/`expiringSoon`/`nameAscending`/`nameDescending`처럼
/// 기준과 방향을 한 케이스에 묶어뒀다. 3기준 × 2방향 = 6개 조합 중 5개만 존재했고,
/// 특히 "만료 내림차순(만료 늦은 순)"을 표현할 방법이 없었다.
public struct Sort: Equatable, Sendable {
public enum Key: Equatable, Sendable {
/// `updatedAt` 기준. 목록 행에 표시되는 날짜와 같은 필드를 써야 사용자가 보는 순서와 정렬 기준이 일치한다.
case time
/// `expiresAt` 기준. 만료일이 없는 Secret은 방향과 무관하게 항상 뒤로 보낸다 — 소비처(정렬 구현부)의 책임.
case expiry
case name
}

public enum Direction: Equatable, Sendable {
case ascending
case descending
}

public var key: Key
public var direction: Direction

public init(key: Key, direction: Direction) {
self.key = key
self.direction = direction
}

/// 기존 `recentlyAdded`와 동일한 기본 정렬 — 최근 수정 순.
public static let recentlyAdded = Sort(key: .time, direction: .descending)
}
}
Loading