diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..6c3b10d552 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -9,6 +9,8 @@ use codexbar::core::HookUsageWindow; use serde::Serialize; use std::sync::Arc; +mod reset_backfill; + const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -594,7 +596,7 @@ async fn refresh_provider( .find(|c| c.provider_id == snapshot.provider_id && c.error.is_none()) .cloned(); let mut snapshot = snapshot; - codex_reset_backfill(&mut snapshot, cached.as_ref()); + reset_backfill::codex_reset_backfill(&mut snapshot, cached.as_ref()); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); if fresh_snapshot { guard @@ -678,82 +680,6 @@ fn dispatch_usage_updated_hook( ); } -/// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing -/// `resets_at` / `reset_description` on fresh Codex windows from the cached -/// lane data when the cached reset is still future. z.ai five-hour cached -/// resets use the same plausibility bound as the provider parser, so an -/// impossible rejected reset cannot be restored from the cache. Fresh -/// `used_percent` is untouched; only reset metadata is backfilled. -/// -/// This remains provider-scoped by design (upstream: "Provider-specific by -/// design"): only Codex and z.ai carry the relevant bounded reset semantics. -/// -/// Applies to the bridge snapshot before publishing so every surface (tray, -/// CLI, frontend) sees the backfilled reset instead of a missing one. -pub(super) fn codex_reset_backfill( - snapshot: &mut ProviderUsageSnapshot, - cached: Option<&ProviderUsageSnapshot>, -) { - let Some(cached) = cached else { return }; - if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { - return; - } - - // Backfill each slot from the corresponding cached slot. - backfill_slot_window( - &snapshot.provider_id, - &mut snapshot.primary, - &cached.primary, - ); - if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { - backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); - } - // Tertiary (monthly/other): the Codex bridge doesn't normally populate this, - // but the slot exists for forward-compat. Backfill when available. - if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { - backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); - } -} - -/// Backfill `resets_at` and `reset_description` on a fresh window from the -/// cached window whose reset is still in the future. `used_percent` is never -/// overwritten (upstream: "fresh used_percent untouched"). -fn backfill_slot_window( - provider_id: &str, - fresh: &mut bridge::RateWindowSnapshot, - cached: &bridge::RateWindowSnapshot, -) { - if fresh.resets_at.is_some() { - return; - } - let Some(cached_reset) = &cached.resets_at else { - return; - }; - // Only backfill when the cached reset is still future — a stale reset is - // worse than a missing one. - let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { - return; - }; - let now = chrono::Utc::now(); - if cached_dt <= now { - return; - } - // A missing z.ai five-hour reset can mean the provider rejected an - // impossible future timestamp. Do not let equally impossible cached - // evidence undo that rejection, but preserve a plausible cached reset. - if provider_id == "zai" - && fresh.window_minutes == Some(300) - && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) - { - return; - } - fresh.resets_at = Some(cached_reset.clone()); - fresh.reset_description = fresh - .reset_description - .clone() - .or_else(|| cached.reset_description.clone()); -} - #[cfg(test)] pub(super) fn preserve_last_good_transient_failure( guard: &mut AppState, @@ -1301,129 +1227,3 @@ mod predictive_warning_tests { } } } - -#[cfg(test)] -mod reset_backfill_tests { - use super::*; - use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; - - fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { - RateWindowSnapshot { - used_percent: used, - remaining_percent: 100.0 - used, - window_minutes: Some(300), - resets_at: resets_at.map(String::from), - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - } - } - - fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { - ProviderUsageSnapshot { - provider_id: "codex".into(), - display_name: "Codex".into(), - primary, - primary_label: None, - secondary: None, - secondary_label: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, - updated_at: "2026-01-01T00:00:00Z".into(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - session_equivalent_forecast: None, - } - } - - #[test] - fn f6_backfills_future_cached_reset() { - // Cached has a future resets_at; fresh has none → backfilled. - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&future))); - let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); - // used_percent is NOT overwritten. - assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); - } - - #[test] - fn f6_does_not_backfill_stale_cached_reset() { - // Cached reset is in the past → not backfilled. - let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&past))); - let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert!( - fresh.primary.resets_at.is_none(), - "stale reset not backfilled" - ); - } - - #[test] - fn f6_does_not_overwrite_existing_resets_at() { - // Fresh already has resets_at → cached not applied. - let future1 = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339(); - let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&future2))); - let mut fresh = codex_snapshot(win(30.0, Some(&future1))); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); - } - - #[test] - fn f6_skips_non_codex_provider() { - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let mut cached = codex_snapshot(win(50.0, Some(&future))); - cached.provider_id = "claude".into(); - let mut fresh = codex_snapshot(win(30.0, None)); - fresh.provider_id = "claude".into(); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); - } - - #[test] - fn zai_five_hour_backfill_rejects_impossible_cached_reset() { - for (offset, should_backfill) in [ - (chrono::Duration::hours(1), true), - (chrono::Duration::hours(10), false), - ] { - let future = (chrono::Utc::now() + offset).to_rfc3339(); - let mut cached = codex_snapshot(win(50.0, Some(&future))); - cached.provider_id = "zai".into(); - let mut fresh = codex_snapshot(win(30.0, None)); - fresh.provider_id = "zai".into(); - fresh.primary.reset_description = Some("5-hour".into()); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); - assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); - } - } - - #[test] - fn f6_skips_when_no_cached_snapshot() { - let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, None); - assert!(fresh.primary.resets_at.is_none()); - } -} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs new file mode 100644 index 0000000000..1bd5642e88 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -0,0 +1,248 @@ +use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; + +/// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing +/// `resets_at` / `reset_description` on fresh Codex windows from the cached +/// lane data when the cached reset is still future. z.ai five-hour cached +/// resets use the same plausibility bound as the provider parser, so an +/// impossible rejected reset cannot be restored from the cache. Fresh +/// `used_percent` is untouched; only reset metadata is backfilled. +/// +/// This remains provider-scoped by design (upstream: "Provider-specific by +/// design"): only Codex and z.ai carry the relevant bounded reset semantics. +/// +/// Applies to the bridge snapshot before publishing so every surface (tray, +/// CLI, frontend) sees the backfilled reset instead of a missing one. +pub(super) fn codex_reset_backfill( + snapshot: &mut ProviderUsageSnapshot, + cached: Option<&ProviderUsageSnapshot>, +) { + let Some(cached) = cached else { return }; + if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { + return; + } + // A subscription change starts a new quota baseline: reset times from the + // previous plan are not evidence for the new one. Unknown plans never block. + if snapshot.provider_id == "codex" && codex_plan_changed(cached, snapshot) { + return; + } + + // Backfill each slot from the corresponding cached slot. + backfill_slot_window( + &snapshot.provider_id, + &mut snapshot.primary, + &cached.primary, + ); + if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { + backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); + } + // Codex rarely populates tertiary windows, but keep this forward-compatible. + if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { + backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); + } +} + +/// True only when both snapshots report a known plan and the plans differ. +fn codex_plan_changed(cached: &ProviderUsageSnapshot, fresh: &ProviderUsageSnapshot) -> bool { + let normalize = |plan: &Option| { + plan.as_deref() + .map(str::trim) + .filter(|plan| !plan.is_empty()) + .map(str::to_lowercase) + }; + matches!( + (normalize(&cached.plan_name), normalize(&fresh.plan_name)), + (Some(cached), Some(fresh)) if cached != fresh + ) +} + +/// Backfill `resets_at` and `reset_description` on a fresh window from the +/// cached window whose reset is still in the future. `used_percent` is never +/// overwritten (upstream: "fresh used_percent untouched"). +fn backfill_slot_window( + provider_id: &str, + fresh: &mut RateWindowSnapshot, + cached: &RateWindowSnapshot, +) { + if fresh.resets_at.is_some() { + return; + } + let Some(cached_reset) = &cached.resets_at else { + return; + }; + // A stale reset is worse than a missing one. + let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { + return; + }; + let now = chrono::Utc::now(); + if cached_dt <= now { + return; + } + // A missing z.ai reset can represent a rejected timestamp; do not restore + // equally implausible cached evidence. + if provider_id == "zai" + && fresh.window_minutes == Some(300) + && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) + { + return; + } + fresh.resets_at = Some(cached_reset.clone()); + fresh.reset_description = fresh + .reset_description + .clone() + .or_else(|| cached.reset_description.clone()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::bridge::RateWindowSnapshot; + + fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { + RateWindowSnapshot { + used_percent: used, + remaining_percent: 100.0 - used, + window_minutes: Some(300), + resets_at: resets_at.map(String::from), + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + } + } + + fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { + ProviderUsageSnapshot { + provider_id: "codex".into(), + display_name: "Codex".into(), + primary, + primary_label: None, + secondary: None, + secondary_label: None, + model_specific: None, + tertiary: None, + tertiary_label: None, + extra_rate_windows: Vec::new(), + inventory: Vec::new(), + display_details: Vec::new(), + cost: None, + plan_name: None, + account_email: None, + subscription: None, + source_label: String::new(), + has_successful_claude_cli_quota: false, + updated_at: "2026-01-01T00:00:00Z".into(), + error: None, + error_state: codexbar::core::ProviderStateKind::Ready, + pace: None, + account_organization: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + session_equivalent_forecast: None, + } + } + + #[test] + fn f6_backfills_future_cached_reset() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&future))); + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + + #[test] + fn f6_does_not_backfill_stale_cached_reset() { + let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&past))); + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!( + fresh.primary.resets_at.is_none(), + "stale reset not backfilled" + ); + } + + #[test] + fn f6_does_not_overwrite_existing_resets_at() { + let future1 = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339(); + let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&future2))); + let mut fresh = codex_snapshot(win(30.0, Some(&future1))); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); + } + + #[test] + fn codex_backfill_skips_when_known_plans_differ() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = Some("Plus".into()); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = Some("Pro".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "plan change baseline"); + } + + #[test] + fn codex_backfill_keeps_baseline_for_same_or_unknown_plan() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + for (cached_plan, fresh_plan) in [ + (Some("Plus"), Some(" plus ")), + (Some("Plus"), None), + (None, Some("Pro")), + (Some("Plus"), Some(" ")), + ] { + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = cached_plan.map(str::to_string); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = fresh_plan.map(str::to_string); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!( + fresh.primary.resets_at.as_deref(), + Some(future.as_str()), + "{cached_plan:?} -> {fresh_plan:?}" + ); + } + } + + #[test] + fn f6_skips_non_codex_provider() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "claude".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "claude".into(); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); + } + + #[test] + fn zai_five_hour_backfill_rejects_impossible_cached_reset() { + for (offset, should_backfill) in [ + (chrono::Duration::hours(1), true), + (chrono::Duration::hours(10), false), + ] { + let future = (chrono::Utc::now() + offset).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "zai".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "zai".into(); + fresh.primary.reset_description = Some("5-hour".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + } + + #[test] + fn f6_skips_when_no_cached_snapshot() { + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, None); + assert!(fresh.primary.resets_at.is_none()); + } +} diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index c27a3158eb..928fe589b7 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -244,6 +244,13 @@ pub(super) fn initial_decision( exact_oauth: bool, observed_at: DateTime, ) -> InitialDecision { + if exact_oauth && plan_changed(state, current) { + // A new subscription has its own quota baseline, not evidence of a + // reset on the previous plan: drop the stored weekly window, pending + // candidate, and credit inventory so the old plan cannot be pinned. + log_reset_diagnostic("planBaseline", "reset", ResetDiagnosticReason::PlanChanged); + *state = AccountState::default(); + } if let Some(candidate) = state.candidate.clone() { match delayed_candidate_decision( state, @@ -325,7 +332,12 @@ pub(super) fn confirmation_decision( if confirmation_weekly.used_percent > RESET_THRESHOLD { return ConfirmationDecision::Publish; } - if initial_weekly.used_percent > RESET_THRESHOLD { + // A near-zero reading is only trusted when both observations report the + // same plan; a plan flip between them is not a confirmation. + if initial_weekly.used_percent > RESET_THRESHOLD + || normalized_plan(initial.login_method.as_deref()) + != normalized_plan(confirmation.login_method.as_deref()) + { return ConfirmationDecision::Preserve; } @@ -629,7 +641,9 @@ pub(super) fn commit_publication( if let Some(weekly) = weekly(snapshot) { state.published_weekly = Some(weekly.clone()); state.published_at = snapshot.updated_at; - state.plan = snapshot.login_method.clone(); + if normalized_plan(snapshot.login_method.as_deref()).is_some() { + state.plan = snapshot.login_method.clone(); + } state.credit_inventory = inventory; state.candidate = None; } @@ -696,16 +710,32 @@ fn supported_delayed_boundary(previous: &RateWindow, current: &RateWindow) -> bo distance.abs() < STABLE_BOUNDARY_TOLERANCE_SECONDS || distance >= RESET_TOLERANCE_SECONDS } +fn normalized_plan(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase) +} + +/// True only when the stored and fresh plans are both known and differ; +/// an unknown plan never resets the baseline. +fn plan_changed(state: &AccountState, current: &UsageSnapshot) -> bool { + if current.updated_at <= state.published_at { + return false; + } + match ( + normalized_plan(state.plan.as_deref()), + normalized_plan(current.login_method.as_deref()), + ) { + (Some(previous), Some(current)) => previous != current, + _ => false, + } +} + fn plans_match(previous: Option<&str>, left: &UsageSnapshot, right: &UsageSnapshot) -> bool { - let normalize = |value: Option<&str>| { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_lowercase) - }; - let previous = normalize(previous); - let left = normalize(left.login_method.as_deref()); - let right = normalize(right.login_method.as_deref()); + let previous = normalized_plan(previous); + let left = normalized_plan(left.login_method.as_deref()); + let right = normalized_plan(right.login_method.as_deref()); previous.is_some() && previous == left && left == right } diff --git a/rust/src/providers/codex/weekly_reset/diagnostics.rs b/rust/src/providers/codex/weekly_reset/diagnostics.rs index 9ce305e8e3..c870b60a28 100644 --- a/rust/src/providers/codex/weekly_reset/diagnostics.rs +++ b/rust/src/providers/codex/weekly_reset/diagnostics.rs @@ -9,6 +9,7 @@ pub(super) enum ResetDiagnosticReason { InconsistentResetBoundary, UnsupportedResetBoundary, PlanMismatch, + PlanChanged, MissingCreditInventory, ChangedCreditInventory, EvidenceVersionMismatch, @@ -33,6 +34,7 @@ impl ResetDiagnosticReason { Self::InconsistentResetBoundary => "inconsistentResetBoundary", Self::UnsupportedResetBoundary => "unsupportedResetBoundary", Self::PlanMismatch => "planMismatch", + Self::PlanChanged => "planChanged", Self::MissingCreditInventory => "missingCreditInventory", Self::ChangedCreditInventory => "changedCreditInventory", Self::EvidenceVersionMismatch => "evidenceVersionMismatch", diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index 224ab8c355..66c7f3de9c 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -527,3 +527,209 @@ fn rolling_weekly_confirmation_keeps_inventory_and_expiry_guards() { "candidate expired" ); } + +fn plan_snapshot(plan: Option<&str>, used: f64, captured_minutes: i64) -> UsageSnapshot { + let mut snapshot = snapshot(used, 9, captured_minutes); + snapshot.login_method = plan.map(str::to_string); + snapshot +} + +/// Plus subscription with a stale 80% weekly baseline that resets in one day. +fn plus_baseline() -> AccountState { + let mut previous = snapshot(80.0, 1, 0); + previous.login_method = Some("ChatGPT Plus".to_string()); + AccountState { + published_weekly: previous.secondary.clone(), + published_at: previous.updated_at, + plan: previous.login_method.clone(), + credit_inventory: Some(inventory("credit-a")), + candidate: None, + } +} + +#[test] +fn plan_upgrade_starts_a_new_baseline_and_publishes_the_new_plan() { + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Pro"), 0.0, 11); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + assert!(state.published_weekly.is_none()); + assert!(state.credit_inventory.is_none()); + assert!(state.candidate.is_none()); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Publish + ); +} + +#[test] +fn same_plan_near_zero_reading_keeps_the_previous_weekly_pinned() { + // Identical to the upgrade scenario, but the plan did not change: the old + // weekly window stays pinned until the confirmation is trustworthy. + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let initial = plan_snapshot(Some("ChatGPT Plus"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Plus"), 0.0, 11); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + assert!(state.published_weekly.is_some()); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve + ); +} + +#[test] +fn plan_upgrade_does_not_pin_the_previous_plan_weekly_window() { + let mut state = plus_baseline(); + let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + let preserved = preserve_weekly(&state, current.clone()); + let used = |snapshot: &UsageSnapshot| snapshot.secondary.as_ref().map(|w| w.used_percent); + assert_eq!(used(&preserved), Some(5.0)); + assert_eq!(used(&preserved), used(¤t)); +} + +#[test] +fn plan_change_discards_a_pending_candidate() { + let mut state = plus_baseline(); + state.candidate = Some(DelayedCandidate { + evidence_version: EVIDENCE_VERSION, + first_observed_at: now(), + created_at: now(), + snapshot_updated_at: now(), + weekly: RateWindow::new(0.0), + plan: Some("ChatGPT Plus".to_string()), + inventory: inventory("credit-a"), + }); + let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + assert!(state.candidate.is_none()); +} + +#[test] +fn same_unknown_stale_or_non_oauth_plans_keep_the_baseline() { + let cases: [(&str, Option<&str>, bool); 4] = [ + ( + "same plan with case and spacing", + Some(" chatgpt plus "), + true, + ), + ("unknown fresh plan", None, true), + ("blank fresh plan", Some(" "), true), + ("not exact OAuth", Some("ChatGPT Pro"), false), + ]; + for (name, plan, exact_oauth) in cases { + let mut state = plus_baseline(); + let current = plan_snapshot(plan, 5.0, 10); + initial_decision(&mut state, ¤t, None, exact_oauth, now()); + assert!(state.published_weekly.is_some(), "{name}"); + assert!(state.credit_inventory.is_some(), "{name}"); + } + + let mut unknown_stored = plus_baseline(); + unknown_stored.plan = None; + let fresh = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + initial_decision(&mut unknown_stored, &fresh, None, true, now()); + assert!( + unknown_stored.published_weekly.is_some(), + "unknown stored plan" + ); + + let mut older = plus_baseline(); + let stale = plan_snapshot(Some("ChatGPT Pro"), 5.0, -1); + initial_decision(&mut older, &stale, None, true, now()); + assert!(older.published_weekly.is_some(), "older observation"); +} + +#[test] +fn unknown_plan_publication_preserves_the_last_known_plan() { + for unknown_plan in [None, Some(" ")] { + let mut state = plus_baseline(); + let inventory = inventory("credit-a"); + let unknown = plan_snapshot(unknown_plan, 50.0, 10); + assert_eq!( + initial_decision(&mut state, &unknown, Some(&inventory), true, now()), + InitialDecision::Publish + ); + commit_publication(&mut state, &unknown, Some(inventory)); + assert_eq!(state.plan.as_deref(), Some("ChatGPT Plus")); + + let changed = plan_snapshot(Some("ChatGPT Pro"), 50.0, 11); + assert_eq!( + initial_decision(&mut state, &changed, None, true, now()), + InitialDecision::Publish + ); + assert!(state.published_weekly.is_none()); + assert!(state.credit_inventory.is_none()); + } +} + +#[test] +fn near_zero_confirmation_must_report_the_initial_plan() { + let inv = inventory("credit-a"); + for confirmation_plan in [Some("ChatGPT Plus"), None] { + for has_baseline in [false, true] { + let mut state = if has_baseline { + baseline() + } else { + AccountState::default() + }; + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(confirmation_plan, 0.0, 11); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve, + "{confirmation_plan:?} baseline {has_baseline}" + ); + assert!(state.candidate.is_none()); + } + } +} + +#[test] +fn nonzero_confirmation_can_publish_its_own_plan() { + let mut state = AccountState::default(); + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Plus"), 5.0, 11); + assert_eq!( + confirmation_decision(&mut state, &initial, None, &confirmation, None, true, now()), + ConfirmationDecision::Publish + ); +}