1#[cfg(test)]
9use jiff::civil;
10use jiff::{SignedDuration, Timestamp};
11
12use super::config::{
13 AbsoluteFormat, CommonRateLimitConfig, ExtraUsageFormat, HourFormat, Locale, PercentFormat,
14 ResetFormat, Timezone,
15};
16use crate::data_context::{CredentialError, ExtraUsage, JsonlError, UsageBucket, UsageError};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum ResetWindow {
22 FiveHour,
23 SevenDay,
24}
25
26impl ResetWindow {
27 fn total(self) -> SignedDuration {
28 match self {
29 Self::FiveHour => SignedDuration::from_hours(5),
30 Self::SevenDay => SignedDuration::from_hours(7 * 24),
31 }
32 }
33}
34
35#[must_use]
42pub(crate) fn format_percent(
43 bucket: &UsageBucket,
44 format: PercentFormat,
45 invert: bool,
46 cfg: &CommonRateLimitConfig,
47) -> String {
48 let raw = f64::from(bucket.utilization.value());
49 let shown = if invert { 100.0 - raw } else { raw };
50 let value = match format {
51 PercentFormat::Percent => format!("{shown:.1}%"),
52 PercentFormat::Progress => format_progress_bar(shown, cfg.progress_width),
53 };
54 wrap(&value, false, cfg)
55}
56
57#[must_use]
64pub(crate) fn format_jsonl_tokens(total: u64, cfg: &CommonRateLimitConfig) -> String {
65 wrap(&format_tokens(total), true, cfg)
66}
67
68#[must_use]
73#[allow(clippy::too_many_arguments)] pub(crate) fn format_reset(
75 resets_at: Timestamp,
76 remaining: SignedDuration,
77 format: &ResetFormat,
78 compact: bool,
79 use_days: bool,
80 window: ResetWindow,
81 jsonl: bool,
82 cfg: &CommonRateLimitConfig,
83) -> String {
84 let value = match format {
85 ResetFormat::Duration => format_duration_text(remaining, compact, use_days),
86 ResetFormat::Absolute(absolute) => format_absolute_text(resets_at, absolute),
87 ResetFormat::Progress => {
88 format_progress_bar(reset_progress_pct(remaining, window), cfg.progress_width)
89 }
90 };
91 wrap(&value, jsonl, cfg)
92}
93
94fn format_absolute_text(resets_at: Timestamp, cfg: &AbsoluteFormat) -> String {
97 let tz = match &cfg.timezone {
98 Timezone::SystemLocal => jiff::tz::TimeZone::system(),
99 Timezone::Iana(tz) => tz.clone(),
100 };
101 let zdt = resets_at.to_zoned(tz);
102 let pattern = match (cfg.hour, cfg.locale) {
103 (HourFormat::Hour24, Locale::EnUs) => "%H:%M %Z",
104 (HourFormat::Hour12, Locale::EnUs) => "%-I:%M %p %Z",
105 };
106 zdt.strftime(pattern).to_string()
107}
108
109#[must_use]
114pub(crate) fn format_extra_usage(
115 extra: &ExtraUsage,
116 format: ExtraUsageFormat,
117 cfg: &CommonRateLimitConfig,
118) -> Option<String> {
119 let value = match format {
120 ExtraUsageFormat::Currency => match (extra.monthly_limit, extra.used_credits) {
121 (Some(limit), Some(used)) => {
122 Some(format_currency(limit - used, extra.currency.as_deref()))
123 }
124 _ => extra.utilization.map(|p| format!("{:.1}%", p.value())),
125 },
126 ExtraUsageFormat::Percent => extra.utilization.map(|p| format!("{:.1}%", p.value())),
127 }?;
128 Some(wrap(&value, false, cfg))
129}
130
131#[must_use]
136pub(crate) fn render_error(err: &UsageError, cfg: &CommonRateLimitConfig) -> String {
137 let body = match err {
138 UsageError::NoCredentials => "[No credentials]",
139 UsageError::Credentials(inner) => match inner {
140 CredentialError::NoCredentials
141 | CredentialError::MissingField { .. }
142 | CredentialError::EmptyToken { .. } => "[No credentials]",
143 CredentialError::SubprocessFailed(_) => "[Keychain error]",
144 CredentialError::IoError { .. } => "[Credentials unreadable]",
145 CredentialError::ParseError { .. } => "[Parse error]",
146 },
147 UsageError::Timeout => "[Timeout]",
148 UsageError::RateLimited { .. } => "[Rate limited]",
149 UsageError::NetworkError => "[Network error]",
150 UsageError::ParseError => "[Parse error]",
151 UsageError::Unauthorized => "[Unauthorized]",
152 UsageError::Jsonl(JsonlError::NoEntries | JsonlError::DirectoryMissing) => "[No data]",
156 UsageError::Jsonl(_) => "[Parse error]",
157 };
158 wrap_label_only(body, cfg)
159}
160
161fn wrap(value: &str, jsonl: bool, cfg: &CommonRateLimitConfig) -> String {
162 let marker = if jsonl { cfg.stale_marker.as_str() } else { "" };
163 let label_sep = if cfg.label.is_empty() { "" } else { ": " };
164 let icon_sep = if cfg.icon.is_empty() { "" } else { " " };
165 format!(
166 "{marker}{icon}{icon_sep}{label}{label_sep}{value}",
167 icon = cfg.icon,
168 label = cfg.label,
169 )
170}
171
172fn wrap_label_only(body: &str, cfg: &CommonRateLimitConfig) -> String {
176 let label_sep = if cfg.label.is_empty() { "" } else { ": " };
177 let icon_sep = if cfg.icon.is_empty() { "" } else { " " };
178 format!(
179 "{icon}{icon_sep}{label}{label_sep}{body}",
180 icon = cfg.icon,
181 label = cfg.label,
182 )
183}
184
185#[must_use]
186pub(crate) fn format_progress_bar(pct: f64, width: u16) -> String {
187 if width == 0 {
191 return format!("{pct:.1}%");
192 }
193 let clamped = pct.clamp(0.0, 100.0);
194 let filled = ((clamped / 100.0) * f64::from(width)).round() as usize;
195 let empty = usize::from(width).saturating_sub(filled);
196 format!(
197 "{filled_bar}{empty_bar} {clamped:.1}%",
198 filled_bar = "█".repeat(filled),
199 empty_bar = "░".repeat(empty),
200 )
201}
202
203fn format_duration_text(remaining: SignedDuration, compact: bool, use_days: bool) -> String {
207 let total_minutes = (remaining.as_secs() / 60).max(0);
208 if total_minutes == 0 {
209 return "<1m".into();
211 }
212
213 let total_hours = total_minutes / 60;
214 let sub_hour_minutes = total_minutes - total_hours * 60;
215
216 if use_days && total_hours >= 24 {
217 let days = (total_hours / 24).min(9999);
219 let hours_within_day = total_hours - (total_hours / 24) * 24;
220 return format_two(days, 'd', hours_within_day, 'h', compact, true);
221 }
222
223 if total_hours > 0 {
224 return if sub_hour_minutes == 0 {
225 format_one(total_hours, 'h', compact, true)
226 } else {
227 format_two(total_hours, 'h', sub_hour_minutes, 'm', compact, false)
228 };
229 }
230
231 format_one(sub_hour_minutes, 'm', compact, false)
232}
233
234fn format_one(value: i64, unit: char, compact: bool, hours_suffix: bool) -> String {
235 match (compact, unit, hours_suffix) {
236 (true, u, _) => format!("{value}{u}"),
237 (false, 'd', _) => format!("{value}d"),
238 (false, 'h', true) => format!("{value}hr"),
239 (false, 'm', _) => format!("{value}m"),
240 (false, c, _) => format!("{value}{c}"),
241 }
242}
243
244fn format_two(
245 a: i64,
246 unit_a: char,
247 b: i64,
248 unit_b: char,
249 compact: bool,
250 hours_suffix: bool,
251) -> String {
252 if compact {
256 if b == 0 {
257 return format!("{a}{unit_a}");
258 }
259 return format!("{a}{unit_a}{b}{unit_b}");
260 }
261 let a_str = match unit_a {
262 'd' => format!("{a}d"),
263 'h' => format!("{a}hr"),
264 _ => format!("{a}{unit_a}"),
265 };
266 if b == 0 {
267 return a_str;
268 }
269 let b_str = match (unit_b, hours_suffix) {
270 ('h', true) => format!("{b}hr"),
271 _ => format!("{b}{unit_b}"),
272 };
273 format!("{a_str} {b_str}")
274}
275
276fn reset_progress_pct(remaining: SignedDuration, window: ResetWindow) -> f64 {
279 let total = window.total();
280 let elapsed = total - remaining;
281 (elapsed.as_millis() as f64 / total.as_millis() as f64).clamp(0.0, 1.0) * 100.0
282}
283
284#[must_use]
290pub(crate) fn format_tokens(total: u64) -> String {
291 const K: u64 = 1_000;
292 const M: u64 = 1_000_000;
293 const G: u64 = 1_000_000_000;
294 if total < K {
295 return total.to_string();
296 }
297 if total < M {
298 return format_unit(total, K, 'k');
299 }
300 if total < G {
301 return format_unit(total, M, 'M');
302 }
303 format_unit(total, G, 'G')
304}
305
306fn format_unit(total: u64, divisor: u64, unit: char) -> String {
307 if total < divisor * 10 {
311 let scaled = (total as f64) / (divisor as f64);
312 format!("{scaled:.1}{unit}")
313 } else {
314 let scaled = total / divisor;
315 format!("{scaled}{unit}")
316 }
317}
318
319fn format_currency(amount: f64, currency: Option<&str>) -> String {
323 let clamped = amount.max(0.0);
324 match currency {
325 None | Some("USD") => format!("${clamped:.2}"),
326 Some(code) => format!("{code} {clamped:.2}"),
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::data_context::{ExtraUsage, UsageBucket};
334 use crate::input::Percent;
335
336 fn cfg() -> CommonRateLimitConfig {
337 CommonRateLimitConfig::new("5h")
338 }
339
340 fn bucket(pct: f64) -> UsageBucket {
341 UsageBucket {
342 utilization: Percent::new(pct as f32).unwrap(),
343 resets_at: None,
344 }
345 }
346
347 #[test]
348 fn percent_format_rounds_to_one_decimal_with_label() {
349 let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &cfg());
350 assert_eq!(s, "5h: 22.0%");
351 }
352
353 #[test]
354 fn percent_format_inverts_when_configured() {
355 let s = format_percent(&bucket(22.0), PercentFormat::Percent, true, &cfg());
356 assert_eq!(s, "5h: 78.0%");
357 }
358
359 #[test]
360 fn percent_format_progress_bar_at_50pct() {
361 let s = format_percent(&bucket(50.0), PercentFormat::Progress, false, &cfg());
362 assert!(s.starts_with("5h: "), "{s}");
363 assert!(s.contains("█"));
364 assert!(s.contains("░"));
365 assert!(s.ends_with("50.0%"), "{s}");
366 }
367
368 #[test]
369 fn jsonl_tokens_compact_format_with_stale_marker() {
370 let s = format_jsonl_tokens(420_000, &cfg());
373 assert_eq!(s, "~5h: 420k");
374 }
375
376 #[test]
377 fn jsonl_tokens_renders_megabytes_when_above_one_million() {
378 let s = format_jsonl_tokens(1_200_000, &cfg());
379 assert_eq!(s, "~5h: 1.2M");
380 }
381
382 #[test]
383 fn jsonl_tokens_empty_stale_marker_suppresses_prefix() {
384 let mut c = cfg();
385 c.stale_marker = String::new();
386 let s = format_jsonl_tokens(420_000, &c);
387 assert_eq!(s, "5h: 420k");
388 }
389
390 #[test]
391 fn empty_label_drops_colon_separator() {
392 let mut c = cfg();
393 c.label = String::new();
394 let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &c);
395 assert_eq!(s, "22.0%");
396 }
397
398 #[test]
399 fn icon_renders_with_space_separator() {
400 let mut c = cfg();
401 c.icon = "⏱".into();
402 let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &c);
403 assert_eq!(s, "⏱ 5h: 22.0%");
404 }
405
406 #[test]
407 fn format_tokens_under_one_thousand_renders_integer() {
408 assert_eq!(format_tokens(0), "0");
409 assert_eq!(format_tokens(999), "999");
410 }
411
412 #[test]
413 fn format_tokens_single_decimal_for_small_k_values() {
414 assert_eq!(format_tokens(5_400), "5.4k");
415 assert_eq!(format_tokens(1_200), "1.2k");
416 }
417
418 #[test]
419 fn format_tokens_drops_decimal_at_ten_k_and_above() {
420 assert_eq!(format_tokens(10_000), "10k");
421 assert_eq!(format_tokens(420_000), "420k");
422 }
423
424 #[test]
425 fn format_tokens_switches_to_megabytes_and_gigabytes() {
426 assert_eq!(format_tokens(1_200_000), "1.2M");
427 assert_eq!(format_tokens(10_000_000), "10M");
428 assert_eq!(format_tokens(1_200_000_000), "1.2G");
429 }
430
431 #[test]
432 fn format_tokens_pins_unit_boundaries() {
433 assert_eq!(format_tokens(1_000), "1.0k");
435 assert_eq!(format_tokens(1_000_000), "1.0M");
436 assert_eq!(format_tokens(1_000_000_000), "1.0G");
437 assert_eq!(format_tokens(9_999), "10.0k");
440 assert_eq!(format_tokens(999_999), "999k");
445 }
446
447 #[test]
448 fn format_tokens_handles_u64_max_without_panic() {
449 let s = format_tokens(u64::MAX);
453 assert!(s.ends_with('G'), "expected G suffix, got {s}");
454 }
455
456 #[test]
457 fn progress_bar_full_at_100pct() {
458 let s = format_progress_bar(100.0, 4);
459 assert_eq!(s, "████ 100.0%");
460 }
461
462 #[test]
463 fn progress_bar_empty_at_zero() {
464 let s = format_progress_bar(0.0, 4);
465 assert_eq!(s, "░░░░ 0.0%");
466 }
467
468 #[test]
469 fn progress_bar_zero_width_collapses_to_percent() {
470 let s = format_progress_bar(50.0, 0);
471 assert_eq!(s, "50.0%");
472 }
473
474 #[test]
475 fn duration_text_sub_minute_renders_lt_1m() {
476 assert_eq!(
477 format_duration_text(SignedDuration::from_secs(30), false, true),
478 "<1m"
479 );
480 }
481
482 #[test]
483 fn duration_text_minutes_only() {
484 assert_eq!(
485 format_duration_text(SignedDuration::from_mins(45), false, true),
486 "45m"
487 );
488 }
489
490 #[test]
491 fn duration_text_hours_and_minutes_non_compact() {
492 assert_eq!(
493 format_duration_text(SignedDuration::from_mins(4 * 60 + 37), false, true),
494 "4hr 37m"
495 );
496 }
497
498 #[test]
499 fn duration_text_hours_and_minutes_compact() {
500 assert_eq!(
501 format_duration_text(SignedDuration::from_mins(4 * 60 + 37), true, true),
502 "4h37m"
503 );
504 }
505
506 #[test]
507 fn duration_text_uses_days_when_configured() {
508 assert_eq!(
509 format_duration_text(SignedDuration::from_mins(27 * 60), false, true),
510 "1d 3hr"
511 );
512 }
513
514 #[test]
515 fn duration_text_skips_days_when_use_days_false() {
516 assert_eq!(
517 format_duration_text(SignedDuration::from_mins(27 * 60), false, false),
518 "27hr"
519 );
520 }
521
522 #[test]
523 fn duration_text_clamps_days_to_four_digits() {
524 let huge = SignedDuration::from_hours(99_999 * 24);
525 let s = format_duration_text(huge, false, true);
526 assert!(s.starts_with("9999d"), "{s}");
527 }
528
529 #[test]
530 fn duration_text_round_hour_drops_minutes() {
531 assert_eq!(
532 format_duration_text(SignedDuration::from_hours(3), false, true),
533 "3hr"
534 );
535 }
536
537 #[test]
538 fn currency_defaults_to_dollar_when_unset() {
539 assert_eq!(format_currency(12.5, None), "$12.50");
540 }
541
542 #[test]
543 fn currency_uses_dollar_for_usd() {
544 assert_eq!(format_currency(12.5, Some("USD")), "$12.50");
545 }
546
547 #[test]
548 fn currency_uses_iso_code_prefix_for_non_usd() {
549 assert_eq!(format_currency(12.5, Some("EUR")), "EUR 12.50");
550 }
551
552 #[test]
553 fn currency_clamps_negative_to_zero() {
554 assert_eq!(format_currency(-5.0, None), "$0.00");
555 }
556
557 fn enabled_extra(limit: Option<f64>, used: Option<f64>, pct: Option<f32>) -> ExtraUsage {
558 ExtraUsage {
559 is_enabled: Some(true),
560 utilization: pct.map(|v| Percent::new(v).unwrap()),
561 monthly_limit: limit,
562 used_credits: used,
563 currency: Some("USD".into()),
564 }
565 }
566
567 #[test]
568 fn extra_currency_renders_remaining_credits() {
569 let e = enabled_extra(Some(100.0), Some(40.0), None);
570 let mut c = cfg();
571 c.label = "extra".into();
572 let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &c).expect("renders");
573 assert_eq!(s, "extra: $60.00");
574 }
575
576 #[test]
577 fn extra_currency_falls_back_to_percent_when_monthly_limit_missing() {
578 let e = enabled_extra(None, Some(40.0), Some(42.5));
581 let mut c = cfg();
582 c.label = "extra".into();
583 let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &c).expect("renders");
584 assert_eq!(s, "extra: 42.5%");
585 }
586
587 #[test]
588 fn extra_returns_none_when_no_data_available() {
589 let e = enabled_extra(None, None, None);
590 let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &cfg());
591 assert_eq!(s, None);
592 }
593
594 #[test]
595 fn error_rendering_covers_spec_table() {
596 let c = cfg();
597 for (err, expected) in [
598 (UsageError::NoCredentials, "5h: [No credentials]"),
599 (
600 UsageError::Credentials(CredentialError::SubprocessFailed(std::io::Error::other(
601 "x",
602 ))),
603 "5h: [Keychain error]",
604 ),
605 (
606 UsageError::Credentials(CredentialError::IoError {
607 path: std::path::PathBuf::from("/x"),
608 cause: std::io::Error::other("x"),
609 }),
610 "5h: [Credentials unreadable]",
611 ),
612 (
613 UsageError::Credentials(CredentialError::ParseError {
614 path: std::path::PathBuf::from("/x"),
615 cause: serde_json::Error::io(std::io::Error::other("x")),
616 }),
617 "5h: [Parse error]",
618 ),
619 (
620 UsageError::Credentials(CredentialError::MissingField {
621 path: std::path::PathBuf::from("/x"),
622 }),
623 "5h: [No credentials]",
624 ),
625 (
626 UsageError::Credentials(CredentialError::EmptyToken {
627 path: std::path::PathBuf::from("/x"),
628 }),
629 "5h: [No credentials]",
630 ),
631 (UsageError::Timeout, "5h: [Timeout]"),
632 (
633 UsageError::RateLimited { retry_after: None },
634 "5h: [Rate limited]",
635 ),
636 (UsageError::NetworkError, "5h: [Network error]"),
637 (UsageError::ParseError, "5h: [Parse error]"),
638 (UsageError::Unauthorized, "5h: [Unauthorized]"),
639 (UsageError::Jsonl(JsonlError::NoEntries), "5h: [No data]"),
640 (
641 UsageError::Jsonl(JsonlError::DirectoryMissing),
642 "5h: [No data]",
643 ),
644 ] {
645 assert_eq!(render_error(&err, &c), expected, "err = {err:?}");
646 }
647 }
648
649 #[test]
650 fn error_rendering_drops_label_when_empty() {
651 let mut c = cfg();
652 c.label = String::new();
653 assert_eq!(render_error(&UsageError::Timeout, &c), "[Timeout]");
654 }
655
656 fn fixed_utc(year: i16, month: i8, day: i8, hour: i8, minute: i8) -> Timestamp {
659 civil::date(year, month, day)
660 .at(hour, minute, 0, 0)
661 .in_tz("UTC")
662 .expect("valid utc")
663 .timestamp()
664 }
665
666 #[test]
667 fn absolute_24h_renders_with_tz_abbreviation() {
668 let resets_at = fixed_utc(2025, 7, 15, 19, 0);
670 let cfg = AbsoluteFormat {
671 timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
672 hour: HourFormat::Hour24,
673 locale: Locale::EnUs,
674 };
675 assert_eq!(format_absolute_text(resets_at, &cfg), "12:00 PDT");
676 }
677
678 #[test]
679 fn absolute_12h_renders_with_am_pm() {
680 let resets_at = fixed_utc(2025, 7, 15, 19, 0);
682 let cfg = AbsoluteFormat {
683 timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
684 hour: HourFormat::Hour12,
685 locale: Locale::EnUs,
686 };
687 assert_eq!(format_absolute_text(resets_at, &cfg), "12:00 PM PDT");
688 }
689
690 #[test]
691 fn absolute_12h_morning_strips_zero_pad() {
692 let resets_at = fixed_utc(2025, 7, 15, 14, 30);
695 let cfg = AbsoluteFormat {
696 timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
697 hour: HourFormat::Hour12,
698 locale: Locale::EnUs,
699 };
700 assert_eq!(format_absolute_text(resets_at, &cfg), "7:30 AM PDT");
701 }
702
703 #[test]
704 fn absolute_renders_in_explicit_zone_distinct_from_utc() {
705 let resets_at = fixed_utc(2025, 1, 15, 0, 0);
709 let cfg = AbsoluteFormat {
710 timezone: Timezone::Iana(jiff::tz::TimeZone::get("Asia/Tokyo").unwrap()),
711 hour: HourFormat::Hour24,
712 locale: Locale::EnUs,
713 };
714 assert_eq!(format_absolute_text(resets_at, &cfg), "09:00 JST");
715 }
716
717 #[test]
718 fn format_reset_dispatches_absolute_branch() {
719 let cfg = cfg();
723 let resets_at = fixed_utc(2025, 7, 15, 19, 0);
724 let format = ResetFormat::Absolute(AbsoluteFormat {
725 timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
726 hour: HourFormat::Hour24,
727 locale: Locale::EnUs,
728 });
729 let out = format_reset(
730 resets_at,
731 SignedDuration::from_hours(1),
732 &format,
733 false,
734 true,
735 ResetWindow::FiveHour,
736 false,
737 &cfg,
738 );
739 assert!(
740 out.contains("12:00 PDT"),
741 "absolute output missing time string: {out}"
742 );
743 assert!(out.contains("5h"), "absolute output missing label: {out}");
744 }
745
746 #[test]
747 fn format_reset_absolute_under_jsonl_keeps_stale_marker() {
748 let mut cfg = cfg();
751 cfg.stale_marker = "~".into();
752 let resets_at = fixed_utc(2025, 7, 15, 19, 0);
753 let format = ResetFormat::Absolute(AbsoluteFormat {
754 timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
755 hour: HourFormat::Hour24,
756 locale: Locale::EnUs,
757 });
758 let out = format_reset(
759 resets_at,
760 SignedDuration::from_hours(1),
761 &format,
762 false,
763 true,
764 ResetWindow::FiveHour,
765 true, &cfg,
767 );
768 assert!(
769 out.starts_with("~"),
770 "expected stale marker on JSONL absolute output: {out}"
771 );
772 assert!(
773 out.contains("12:00 PDT"),
774 "absolute output missing time string: {out}"
775 );
776 }
777
778 #[test]
779 fn absolute_renders_correct_zone_across_dst_transition() {
780 let cfg = AbsoluteFormat {
784 timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
785 hour: HourFormat::Hour24,
786 locale: Locale::EnUs,
787 };
788 let pre = fixed_utc(2025, 3, 9, 9, 30);
790 assert_eq!(format_absolute_text(pre, &cfg), "01:30 PST");
791 let post = fixed_utc(2025, 3, 9, 11, 30);
793 assert_eq!(format_absolute_text(post, &cfg), "04:30 PDT");
794 }
795}