diff --git a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift index 602beba0..de08c504 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift @@ -70,6 +70,7 @@ extension DVVaultContainerPreviewView { service: vaults[index].3, typeIcon: vaults[index].4 ? placeholderTypeIcon : nil, trailingIcon: vaults[index].2, + trailingIconTooltip: vaults[index].2 != nil ? "Expires soon" : nil, isSelected: selectedIndex == index ) .tag(index) diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index 760dff37..cc3627e7 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -15,6 +15,8 @@ public struct DVVaultContainer: View { public let typeIcon: Image? /// 우측 만료 강조 아이콘. 어떤 단계로 볼지는 호출부의 만료 정책이 결정한다. public let trailingIcon: DVExpiryEmphasis? + /// `trailingIcon`에 hover 시 뜨는 설명 문구. `trailingIcon`이 `nil`이면 무시된다. + public let trailingIconTooltip: String? public let isSelected: Bool // MARK: - Init @@ -25,6 +27,7 @@ public struct DVVaultContainer: View { service: String? = nil, typeIcon: Image? = nil, trailingIcon: DVExpiryEmphasis? = nil, + trailingIconTooltip: String? = nil, isSelected: Bool = false ) { self.name = name @@ -32,6 +35,7 @@ public struct DVVaultContainer: View { self.service = service self.typeIcon = typeIcon self.trailingIcon = trailingIcon + self.trailingIconTooltip = trailingIconTooltip self.isSelected = isSelected } @@ -142,6 +146,8 @@ extension DVVaultContainer { trailingIcon.icon .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(trailingIcon.colorToken)) .fixedSize() + // `.help(_:)`가 List 행 안에서 안 떠서 커스텀 말풍선으로 우회한다. + .hoverTooltip(trailingIconTooltip) } } } diff --git a/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift new file mode 100644 index 00000000..a3c7a1d5 --- /dev/null +++ b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift @@ -0,0 +1,139 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// hover 시 커스텀 말풍선을 띄우는 툴팁 modifier. +/// +/// `.help(_:)`와 `NSView.toolTip` 둘 다 `List` 행 안에서 안 떴다(마우스 이벤트는 도달하는데 +/// 시스템 tooltip 렌더링만 안 됨). `.overlay`로 직접 그리면 `List`가 폭 밖 콘텐츠를 잘라서, +/// 화면 절대 좌표의 별도 `NSWindow`로 띄운다 — 실제 시스템 tooltip과 같은 방식. +extension View { + /// `text`가 `nil`이면 아무것도 붙이지 않는다. + public func hoverTooltip(_ text: String?) -> some View { + overlay { + if let text { + HoverTooltipHost(text: text) + } + } + } +} + +private struct HoverTooltipHost: NSViewRepresentable { + let text: String + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.text = text + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) { + nsView.text = text + } +} + +/// hover를 감지해 별도 `NSPanel`에 말풍선을 띄우는 뷰. +private final class TrackingView: NSView { + var text: String? + + private var trackingArea: NSTrackingArea? + private var tooltipPanel: NSPanel? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let newTrackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(newTrackingArea) + trackingArea = newTrackingArea + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + showTooltip() + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + hideTooltip() + } + + override func removeFromSuperview() { + hideTooltip() + super.removeFromSuperview() + } + + // hover 전용이라 히트테스트에서 빠진다 — 안 그러면 배지 클릭이 행 선택을 막을 수 있다. + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + deinit { + hideTooltip() + } + + private func showTooltip() { + guard let text, let window, !text.isEmpty else { return } + // mouseExited 없이 mouseEntered가 다시 올 수 있어(트래킹 재등록 시) 먼저 정리한다. + hideTooltip() + + let hosting = NSHostingView(rootView: TooltipBubble(text: text)) + let size = hosting.fittingSize + hosting.frame = CGRect(origin: .zero, size: size) + + let panel = NSPanel( + contentRect: CGRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .popUpMenu + panel.ignoresMouseEvents = true + panel.isReleasedWhenClosed = false + panel.contentView = hosting + + let boundsInWindow = convert(bounds, to: nil) + let boundsOnScreen = window.convertToScreen(boundsInWindow) + let origin = CGPoint( + x: boundsOnScreen.midX - size.width / 2, + y: boundsOnScreen.minY - size.height - 6 + ) + panel.setFrameOrigin(origin) + window.addChildWindow(panel, ordered: .above) + tooltipPanel = panel + } + + private func hideTooltip() { + if let tooltipPanel { + tooltipPanel.parent?.removeChildWindow(tooltipPanel) + tooltipPanel.orderOut(nil) + } + tooltipPanel = nil + } +} + +/// hover 말풍선. macOS 시스템 tooltip과 비슷한 외관(밝은 회색 배경 + 어두운 텍스트)으로 맞춘다. +private struct TooltipBubble: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(Color(NSColor.labelColor)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(NSColor.windowBackgroundColor), in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + .fixedSize() + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift b/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift new file mode 100644 index 00000000..9458e49f --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// "만료가 임박했다"를 판단하는 기간 상수의 단일 소스. +/// +/// 같은 개념(3일 / 7일 / 30일)이 배지 표시(`SecretExpiryStatus`), Expired 탭 섹션 분류 +/// (`ExpiryBucket`), Expired 탭 쿼리 범위(`SecretQuery.Collection.expiringWindow`)에 +/// 각각 필요하다. 값을 여기 하나로 모아두지 않으면 한 곳만 바뀌었을 때 나머지가 조용히 +/// 어긋난다. 만료 알림(`ScheduleSecretExpiryNotificationsUseCaseImpl`)은 값이 같을 뿐 +/// 의도적으로 여기 묶여있지 않다 — identifier 재구성 때문에 독립 상수를 쓴다. +/// +/// 판정 로직(무엇을 배지로 보여줄지, 무엇을 섹션으로 나눌지)은 목적마다 달라 여기서 +/// 통합하지 않는다. 여기서는 값만 소유한다. +public enum SecretExpiryPolicy { + + /// 즉시 조치가 필요한 단계로 볼 기간(일). + public static let criticalWindowDays = 3 + + /// 아직 조치할 시간이 있는 예고 단계로 볼 기간(일). + public static let upcomingWindowDays = 7 + + /// Expired 탭에 "만료 예정"으로 함께 보여줄 범위(일). + public static let listingWindowDays = 30 +} diff --git a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift index e69bc2c0..2c67cec8 100644 --- a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift +++ b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift @@ -38,19 +38,16 @@ extension SecretQuery { case deleted case project(id: UUID) - /// Notice에 담을 "만료 임박" 기간(일). 목록 행 배지의 upcoming window(7일)와 같은 기준을 써야 + /// Notice에 담을 "만료 임박" 기간(일). 목록 행 배지의 upcoming window와 같은 기준을 써야 /// 사이드바 카드 숫자와 배지가 뜨는 시크릿 집합이 어긋나지 않는다. - public static let noticeWindowDays = 7 + public static let noticeWindowDays = SecretExpiryPolicy.upcomingWindowDays /// `referenceDate`로부터 `noticeWindowDays`만큼 민 시각. public static func noticeWindowEnd(from referenceDate: Date) -> Date { referenceDate.addingTimeInterval(TimeInterval(noticeWindowDays) * 86_400) } - /// Expired 범위에 함께 담을 "만료 예정" 기간(일). - public static let expiringSoonWindowDays = 30 - - /// "이미 지남 + `expiringSoonWindowDays`일 이내 만료 예정"을 한 번에 담는 컬렉션. + /// "이미 지남 + `SecretExpiryPolicy.listingWindowDays`일 이내 만료 예정"을 한 번에 담는 컬렉션. /// /// `expired` predicate는 `expiresAt < referenceDate` 단일 비교라, 기준일을 window만큼 /// 미래로 밀어서 두 범위를 함께 가져온다. 목록 조회와 사이드바 개수 집계가 **같은 함수**를 @@ -59,7 +56,7 @@ extension SecretQuery { public static func expiringWindow(from referenceDate: Date) -> Self { .expired( referenceDate: referenceDate.addingTimeInterval( - TimeInterval(expiringSoonWindowDays) * 86_400 + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 ) ) } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift index 2e89ded6..25e94534 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift @@ -5,7 +5,9 @@ import Foundation import DVCore public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiryNotificationsUseCase { - private static let daysBeforeExpiry = [7, 3] + /// `SecretExpiryPolicy`와 값이 같지만 우연일 뿐 의도적 결합 아님 — `cancel`이 이 값으로 + /// identifier를 재구성해서, 참조하면 값 변경 시 이전 예약을 못 지운다. + private static let notificationLeadDays = [7, 3] private let repository: any SecretRepository private let notificationService: any SecurityNotificationService @@ -44,7 +46,7 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry // expiresAt이 바뀌었을 수 있어 이전 마크가 stale하게 남지 않도록 먼저 전부 취소한다. await cancel(secretID: secret.id) - for daysBefore in Self.daysBeforeExpiry { + for daysBefore in Self.notificationLeadDays { guard let dayMark = Calendar.current.date(byAdding: .day, value: -daysBefore, to: expiresAt) else { continue } @@ -71,8 +73,8 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry } public func cancel(secretID: UUID) async { - // daysBeforeExpiry에 대응하는 identifier를 전부 취소 — 이미 소비된 것도 무시되니 존재 확인 안함 - let identifiers = Self.daysBeforeExpiry.map { Self.notificationID(secretID: secretID, daysBefore: $0) } + // notificationLeadDays에 대응하는 identifier를 전부 취소 — 이미 소비된 것도 무시되니 존재 확인 안함 + let identifiers = Self.notificationLeadDays.map { Self.notificationID(secretID: secretID, daysBefore: $0) } await notificationService.cancel(identifiers: identifiers) } diff --git a/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift b/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift new file mode 100644 index 00000000..68cffb9c --- /dev/null +++ b/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift @@ -0,0 +1,31 @@ +// Copyright © 2026 Devault. All rights reserved + +import Testing + +@testable import DVDomain + +@Suite("SecretExpiryPolicy") +struct SecretExpiryPolicyTests { + + /// 순서 검증만으로는 값이 바뀌어도 항상 통과해 회귀를 못 잡는다. + @Test("criticalWindowDays는 3일이다") + func criticalWindowDaysIsThree() { + #expect(SecretExpiryPolicy.criticalWindowDays == 3) + } + + @Test("upcomingWindowDays는 7일이다") + func upcomingWindowDaysIsSeven() { + #expect(SecretExpiryPolicy.upcomingWindowDays == 7) + } + + @Test("listingWindowDays는 30일이다") + func listingWindowDaysIsThirty() { + #expect(SecretExpiryPolicy.listingWindowDays == 30) + } + + @Test("단계별 기간은 critical < upcoming < listing 순으로 넓어진다") + func windowsAreOrdered() { + #expect(SecretExpiryPolicy.criticalWindowDays < SecretExpiryPolicy.upcomingWindowDays) + #expect(SecretExpiryPolicy.upcomingWindowDays < SecretExpiryPolicy.listingWindowDays) + } +} diff --git a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift index bf8069c6..337b173e 100644 --- a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift +++ b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift @@ -35,9 +35,9 @@ struct SecretQueryTests { #expect(a != b) } - @Test("noticeWindowDays는 7일이다") + @Test("noticeWindowDays는 배지의 upcoming window와 같은 7일이다") func noticeWindowDaysIsSevenDays() { - // upcomingWindow와의 일치 여부는 DVPresentation쪽 SecretExpiryStatusTests가 검증한다. + #expect(SecretQuery.Collection.noticeWindowDays == SecretExpiryPolicy.upcomingWindowDays) #expect(SecretQuery.Collection.noticeWindowDays == 7) } @@ -53,14 +53,14 @@ struct SecretQueryTests { #expect(windowEnd == expected) } - @Test("expiringWindow는 기준일을 expiringSoonWindowDays만큼 민 expired 컬렉션을 만든다") + @Test("expiringWindow는 기준일을 listingWindowDays만큼 민 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 + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 ) #expect(collection == .expired(referenceDate: expected)) } @@ -68,7 +68,7 @@ struct SecretQueryTests { @Test("expiringWindow는 이미 만료된 것과 window 이내 예정을 함께 담는다") func expiringWindowCoversPastAndUpcoming() { let today = Date(timeIntervalSince1970: 0) - let windowDays = TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) + let windowDays = TimeInterval(SecretExpiryPolicy.listingWindowDays) guard case let .expired(windowEnd) = SecretQuery.Collection.expiringWindow(from: today) else { Issue.record("collection이 .expired가 아님") diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift index fcd43053..451c3cb4 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift @@ -55,6 +55,7 @@ private func _secret(expiresAt: Date?) -> Secret { ) } +/// 이미 지난 만료일(-5일)도 강조된다 — 목록 배지와 달리 필터링하지 않는다. #Preview("만료 임박 4단계 · paired") { VStack(alignment: .leading, spacing: 16) { DetailExpireDateFieldView(secret: _secret(expiresAt: .now.addingTimeInterval(-5 * 86_400))) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 8cb62dd1..6b62183e 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -45,7 +45,11 @@ public struct SecretListFeature { case .notice: // predicate가 이미 "지나지 않음 + window 이내"를 전부 검사하므로 window 변환이 필요 없다. // 임박한 것부터 보여주는 게 자연스러워 정렬은 고정한다(사용자가 바꿀 이유가 없는 화면). - return SecretQuery(collection: collection, searchText: normalizedSearchText, sort: .expiringSoon) + return SecretQuery( + collection: collection, + searchText: normalizedSearchText, + sort: SecretQuery.Sort(key: .expiry, direction: .ascending) + ) case .all, .liked, .deleted, .project: return SecretQuery(collection: collection, searchText: normalizedSearchText, sort: sort) } diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index b1b0dc82..8b25d636 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -89,12 +89,16 @@ extension SecretListView { } private func row(for secret: Secret) -> some View { - DVVaultContainer( + // 이미 지난 건 배지로 안 보여준다 — Expired 탭이 전담한다. + let badgeStatus = expiryStatus(for: secret).flatMap { $0 == .expired ? nil : $0 } + + return DVVaultContainer( name: secret.name, date: SecretDateFormatter.string(from: secret.updatedAt), service: secret.service, typeIcon: secret.secretType.icon, - trailingIcon: trailingIcon(for: secret), + trailingIcon: badgeStatus?.emphasis, + trailingIconTooltip: badgeStatus?.tooltipText, isSelected: secret.id == store.selectedSecretID ) .tag(secret.id) @@ -107,9 +111,9 @@ extension SecretListView { } /// All/Star/Expired/Deleted 어디서든 만료 상태를 알려준다. - /// 임계값은 `SecretExpiryStatus`가 소유한다 — 조회 화면 Expire Date 필드와 같은 정책을 써야 한다. - private func trailingIcon(for secret: Secret) -> DVExpiryEmphasis? { - SecretExpiryStatus(expiresAt: secret.expiresAt)?.emphasis + /// 임계값은 `SecretExpiryPolicy`가 소유한다 — 조회 화면 Expire Date 필드와 같은 정책을 써야 한다. + private func expiryStatus(for secret: Secret) -> SecretExpiryStatus? { + SecretExpiryStatus(expiresAt: secret.expiresAt) } /// All/Star/Expired는 "프로젝트에 추가/삭제", Deleted는 "복구/영구 삭제"를 보여준다. @@ -242,7 +246,7 @@ extension SecretListView { // MARK: - ExpiryBucket -/// Expired 탭의 섹션 구분. 경계는 오늘 기준 7일/30일로 고정. +/// Expired 탭의 섹션 구분. 경계는 `SecretExpiryPolicy`의 upcoming/listing window를 그대로 쓴다. private enum ExpiryBucket: CaseIterable, Identifiable { case expired @@ -253,17 +257,24 @@ private enum ExpiryBucket: CaseIterable, Identifiable { var title: String { switch self { - case .expired: return "Expired" - case .within7Days: return "Expires in 7 days" - case .within30Days: return "Expires in 30 days" + case .expired: + return String.module("Expired") + case .within7Days: + return String.module("Expires in \(SecretExpiryPolicy.upcomingWindowDays) days") + case .within30Days: + return String.module("Expires in \(SecretExpiryPolicy.listingWindowDays) days") } } func contains(_ expiresAt: Date?, referenceDate: Date) -> Bool { guard let expiresAt else { return false } - // Notice 탭과 같은 "7일" 기준을 쓴다. - let sevenDaysOut = SecretQuery.Collection.noticeWindowEnd(from: referenceDate) - let thirtyDaysOut = referenceDate.addingTimeInterval(30 * 86_400) + // Notice 탭(`noticeWindowDays`)과 같은 값에서 파생된다. + let sevenDaysOut = referenceDate.addingTimeInterval( + TimeInterval(SecretExpiryPolicy.upcomingWindowDays) * 86_400 + ) + let thirtyDaysOut = referenceDate.addingTimeInterval( + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 + ) switch self { case .expired: diff --git a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift index 95f0393d..05b3b5c8 100644 --- a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift +++ b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift @@ -5,16 +5,15 @@ import Foundation import DVDesign import DVDomain -/// 만료 임박 표기 정책의 단일 정의부 — 목록(`SecretListView`)과 조회(`DetailExpireDateFieldView`)가 함께 쓴다. -/// -/// 두 화면이 임계값을 각자 들고 있으면 같은 시크릿이 목록에선 표시가 없고 조회에선 강조되는 -/// 모순이 생긴다. 판정은 이 타입만 하고, 표현(아이콘·색)은 ``DVExpiryEmphasis``가 갖는다. +/// 만료 임박 표기 정책의 단일 정의부 — 목록과 조회 화면이 함께 쓴다. +/// "이미 지난 것을 뺄지"는 소비처(`SecretListView.row(for:)`) 몫이다 — 여기서 걸러내면 +/// 조회 화면도 강제로 끌려간다. enum SecretExpiryStatus: Equatable { - /// 이미 만료됐거나 ``criticalWindow`` 이내에 만료된다. - /// - /// 이미 지난 경우를 별도 단계로 두지 않는 것은 의도된 정책이다 — - /// 사용자가 취해야 할 조치(갱신)가 같으므로 구분해서 보여줄 이유가 없다. + /// 이미 만료됐다. + case expired + + /// 아직 안 지났고 ``criticalWindow`` 이내에 만료된다 — 즉시 조치가 필요한 단계. case critical /// ``upcomingWindow`` 이내에 만료된다 — 아직 조치할 시간이 있는 예고 단계. @@ -24,22 +23,19 @@ enum SecretExpiryStatus: Equatable { /// 목록의 `ExpiryBucket`과 같은 기준을 쓰기 위한 것이다. private static let secondsPerDay: TimeInterval = 86_400 - /// 남은 기간이 이 값 이하(이미 지나 음수인 경우 포함)면 ``critical``. - static let criticalWindow: TimeInterval = 3 * secondsPerDay + static let criticalWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.criticalWindowDays) * secondsPerDay /// 남은 기간이 ``criticalWindow`` 초과이면서 이 값 이하면 ``upcoming``. - /// Notice 탭과 같은 "7일" 기준을 쓰기 위해 `noticeWindowDays`에서 파생시킨다. - static let upcomingWindow = TimeInterval(SecretQuery.Collection.noticeWindowDays) * secondsPerDay - - /// 만료일로부터 상태를 산출한다. 만료일이 없거나 ``upcomingWindow``보다 멀면 `nil` — 아무 표시도 하지 않는다. - /// - /// - Parameters: - /// - expiresAt: 시크릿의 만료일. `nil`이면 만료 개념이 없는 시크릿이다. - /// - now: 판정 기준 시각. 테스트가 고정 시각을 주입한다. + /// Notice 탭(`SecretQuery.Collection.noticeWindowDays`)도 같은 값을 파생시켜 쓴다. + static let upcomingWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.upcomingWindowDays) * secondsPerDay + + /// 만료일이 없거나 ``upcomingWindow``보다 멀면 `nil`. init?(expiresAt: Date?, now: Date = .now) { guard let expiresAt else { return nil } - if expiresAt <= now.addingTimeInterval(Self.criticalWindow) { + if expiresAt <= now { + self = .expired + } else if expiresAt <= now.addingTimeInterval(Self.criticalWindow) { self = .critical } else if expiresAt <= now.addingTimeInterval(Self.upcomingWindow) { self = .upcoming @@ -51,8 +47,20 @@ enum SecretExpiryStatus: Equatable { /// 단계별 표현. 목록 행과 조회 필드가 이 하나를 통해 같은 아이콘·색을 얻는다. var emphasis: DVExpiryEmphasis { switch self { - case .critical: return .danger + case .expired, .critical: return .danger case .upcoming: return .warning } } + + /// 배지에 hover 시 뜨는 설명 문구. 아이콘·색만으로는 "며칠 남았는지"가 전달되지 않는다. + var tooltipText: String { + switch self { + case .expired: + return String.module("Expired") + case .critical: + return String.module("Expires within \(SecretExpiryPolicy.criticalWindowDays) days") + case .upcoming: + return String.module("Expires within \(SecretExpiryPolicy.upcomingWindowDays) days") + } + } } diff --git a/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift b/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift index 58058e5a..27edbdf2 100644 --- a/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift +++ b/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift @@ -28,19 +28,21 @@ struct SecretExpiryStatusTests { #expect(SecretExpiryStatus(expiresAt: nil, now: Self.now) == nil) } - // MARK: - critical (이미 만료 + 3일 이내) + // MARK: - expired (이미 지남) - @Test("이미 만료된 경우 critical — 3일 이내와 구분하지 않는다") - func alreadyExpiredIsCritical() { - #expect(Self.status(daysFromNow: -30) == .critical) - #expect(Self.status(daysFromNow: -1) == .critical) + @Test("이미 만료된 경우 expired") + func alreadyExpiredIsExpired() { + #expect(Self.status(daysFromNow: -30) == .expired) + #expect(Self.status(daysFromNow: -1) == .expired) } - @Test("정확히 지금 만료되는 경우 critical") - func expiringExactlyNowIsCritical() { - #expect(Self.status(daysFromNow: 0) == .critical) + @Test("정확히 지금 만료되는 경우 expired") + func expiringExactlyNowIsExpired() { + #expect(Self.status(daysFromNow: 0) == .expired) } + // MARK: - critical (아직 안 지났고 3일 이내) + @Test("3일 이내는 critical") func withinThreeDaysIsCritical() { #expect(Self.status(daysFromNow: 1) == .critical) @@ -81,8 +83,9 @@ struct SecretExpiryStatusTests { // MARK: - 표현 매핑 - @Test("critical은 danger, upcoming은 warning으로 강조된다") + @Test("expired·critical은 danger, upcoming은 warning으로 강조된다") func emphasisMapping() { + #expect(SecretExpiryStatus.expired.emphasis == DVExpiryEmphasis.danger) #expect(SecretExpiryStatus.critical.emphasis == DVExpiryEmphasis.danger) #expect(SecretExpiryStatus.upcoming.emphasis == DVExpiryEmphasis.warning) } @@ -103,4 +106,13 @@ struct SecretExpiryStatusTests { == TimeInterval(SecretQuery.Collection.noticeWindowDays) * 86_400 ) } + + // MARK: - tooltip 문구 + + @Test("expired는 고정 문구, critical은 3일, upcoming은 7일 문구를 갖는다") + func tooltipTextReflectsWindowDays() { + #expect(SecretExpiryStatus.expired.tooltipText == "Expired") + #expect(SecretExpiryStatus.critical.tooltipText.contains("3")) + #expect(SecretExpiryStatus.upcoming.tooltipText.contains("7")) + } } diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index 38eb5e5e..ab6ea212 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -144,13 +144,13 @@ struct SecretListFeatureTests { return } let expectedWindowEnd = today.addingTimeInterval( - TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) * 86_400 + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 ) #expect(windowEnd == expectedWindowEnd) #expect(query.sort == SecretQuery.Sort(key: .expiry, direction: .ascending)) } - @Test("notice collection의 query는 collection을 그대로 쓰고 expiringSoon 정렬을 강제한다") + @Test("notice collection의 query는 collection을 그대로 쓰고 만료 오름차순 정렬을 강제한다") func noticeQueryForcesExpiringSoonSort() { let today = Date(timeIntervalSince1970: 0) let state = SecretListFeature.State(collection: .notice(referenceDate: today)) @@ -159,7 +159,7 @@ struct SecretListFeatureTests { // predicate가 이미 window 전체를 검사하므로 .expired와 달리 collection 변환이 필요 없다. #expect(query.collection == .notice(referenceDate: today)) - #expect(query.sort == .expiringSoon) + #expect(query.sort == SecretQuery.Sort(key: .expiry, direction: .ascending)) } @Test("didSelectSecret은 selectedSecretID를 갱신하고 delegate로 알린다")