diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 9b4292517..24a11ef53 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -275,7 +275,7 @@ final class UsageStore { } /// 표시용 한도 % 변환 — remaining 모드면 100−사용률(0 하한: 사용률이 100 을 넘어도 음수 금지). - /// 순수 판정을 분리해 테스트한다. 숫자 *표시* 전용 — 색·게이지·알림 판정에는 쓰지 않는다. + /// 숫자와 게이지 채움에 함께 사용한다. 경고색·알림 판정은 원래 사용률을 유지한다. nonisolated static func displayPercent(_ utilization: Double, mode: LimitDisplayMode) -> Double { mode == .remaining ? max(0, 100 - utilization) : utilization } diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index bd9801a53..e508529fc 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -464,9 +464,7 @@ struct PopoverView: View { .foregroundStyle(.tertiary) } } - ProgressView(value: min(utilization, 100), total: 100) - .tint(limitColor(utilization)) - .controlSize(.small) + LimitProgressBar(usedPercent: utilization, tint: limitColor(utilization)) } } @@ -521,7 +519,7 @@ struct PopoverView: View { /// 한도 % 표시 문자열 — remaining 모드면 남은 %에 자기설명 접미사("남음/left/残り"). - /// 게이지 채움·경고색은 사용률 원값 기준 유지 — 숫자 텍스트만 모드를 따른다. + /// 게이지 채움도 같은 표시 모드를 따르며, 경고색은 실제 사용률로 판단한다. private func limitPercentText(_ utilization: Double) -> String { let text = TokenFormatter.percent(store.limitDisplayPercent(utilization)) return store.limitDisplayMode == .remaining ? l.percentRemaining(text) : text @@ -566,9 +564,7 @@ struct PopoverView: View { .foregroundStyle(.tertiary) } } - ProgressView(value: min(utilization, 100), total: 100) - .tint(limitColor(utilization)) - .controlSize(.small) + LimitProgressBar(usedPercent: utilization, tint: limitColor(utilization)) } } } @@ -737,9 +733,7 @@ struct PopoverView: View { .foregroundStyle(.tertiary) } } - ProgressView(value: min(utilization, 100), total: 100) - .tint(limitColor(utilization)) - .controlSize(.small) + LimitProgressBar(usedPercent: utilization, tint: limitColor(utilization)) } } } @@ -765,9 +759,7 @@ struct PopoverView: View { .font(.caption) .foregroundStyle(.tertiary) } - ProgressView(value: min(utilization, 100), total: 100) - .tint(limitColor(utilization)) - .controlSize(.small) + LimitProgressBar(usedPercent: utilization, tint: limitColor(utilization)) } } } @@ -1081,3 +1073,17 @@ struct ProviderTabBar: View { .scrollBounceBehavior(.basedOnSize, axes: .horizontal) } } + +/// Text and fill describe the same quantity; warning colors still represent actual usage. +@MainActor +struct LimitProgressBar: View { + let usedPercent: Double + let tint: Color + @Environment(UsageStore.self) private var store + + var body: some View { + ProgressView(value: min(100, max(0, store.limitDisplayPercent(usedPercent))), total: 100) + .tint(tint) + .controlSize(.small) + } +} diff --git a/Tests/PokeTokenBarTests/LimitProgressRenderingTests.swift b/Tests/PokeTokenBarTests/LimitProgressRenderingTests.swift new file mode 100644 index 000000000..4572b78f5 --- /dev/null +++ b/Tests/PokeTokenBarTests/LimitProgressRenderingTests.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI +import XCTest +@testable import PokeTokenBar + +@MainActor +final class LimitProgressRenderingTests: XCTestCase { + func testRemainingModeReversesRenderedFillAndClampsExhaustedQuota() throws { + let suite = "LimitProgressRendering-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = UsageStore(providers: [], autoRefresh: false, defaults: defaults) + + func renderedPercent(_ used: Double) throws -> Double { + let view = LimitProgressBar(usedPercent: used, tint: .blue) + .environment(store).frame(width: 240, height: 20) + let host = NSHostingController(rootView: view) + host.view.frame = NSRect(x: 0, y: 0, width: 240, height: 20) + host.view.layoutSubtreeIfNeeded() + func descendants(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(descendants) + } + let views = descendants(host.view) + let bar = try XCTUnwrap(views.compactMap { $0 as? NSProgressIndicator }.first, + views.map { String(describing: type(of: $0)) }.joined(separator: ",")) + return bar.doubleValue / bar.maxValue * 100 + } + + store.limitDisplayMode = .used + XCTAssertEqual(try renderedPercent(25), 25, accuracy: 0.01) + store.limitDisplayMode = .remaining + XCTAssertEqual(try renderedPercent(25), 75, accuracy: 0.01) + XCTAssertEqual(try renderedPercent(125), 0, accuracy: 0.01) + XCTAssertEqual(try renderedPercent(0), 100, accuracy: 0.01) + store.limitDisplayMode = .used + XCTAssertEqual(try renderedPercent(25), 25, accuracy: 0.01) + } +}