| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/billing" |
| 10 | "reasonix/internal/control" |
| 11 | ) |
| 12 | |
| 13 | type billingRuntimeController struct { |
| 14 | stubSessionAPI |
| 15 | status control.RuntimeStatus |
| 16 | started chan struct{} |
| 17 | release chan struct{} |
| 18 | balance *billing.Balance |
| 19 | err error |
| 20 | } |
| 21 | |
| 22 | func (c *billingRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status } |
| 23 | func (c *billingRuntimeController) SessionPath() string { return "" } |
| 24 | |
| 25 | func (c *billingRuntimeController) Balance(ctx context.Context) (*billing.Balance, error) { |
| 26 | if c.started != nil { |
| 27 | close(c.started) |
| 28 | } |
| 29 | if c.release != nil { |
| 30 | select { |
| 31 | case <-c.release: |
| 32 | case <-ctx.Done(): |
| 33 | return nil, ctx.Err() |
| 34 | } |
| 35 | } |
| 36 | return c.balance, c.err |
| 37 | } |
| 38 | |
| 39 | func TestSetDesktopCurrencyRebindsLedgerWithoutControllerRebuild(t *testing.T) { |
| 40 | isolateDesktopUserDirs(t) |
| 41 | ctrl := &billingRuntimeController{status: control.RuntimeStatus{Running: true}} |
| 42 | ledger := billing.NewLedger() |
| 43 | ledger.Add(billing.CostQuote{ |
| 44 | Original: billing.Money{Amount: "1", Currency: "USD"}, |
| 45 | Valuations: map[string]billing.Valuation{ |
| 46 | "USD": {Money: billing.Money{Amount: "1", Currency: "USD"}, Basis: billing.BasisIdentity}, |
| 47 | "CNY": {Money: billing.Money{Amount: "7", Currency: "CNY"}, Basis: billing.BasisOfficialTable}, |
| 48 | }, |
| 49 | Selected: &billing.Money{Amount: "1", Currency: "USD"}, |
| 50 | Estimated: true, |
| 51 | CostComplete: true, |
| 52 | DisplayComplete: true, |
| 53 | Complete: true, |
| 54 | DisplayStatus: billing.DisplayStatusMatched, |
| 55 | ModelRef: "deepseek-flash/deepseek-v4-flash", |
| 56 | }, billing.UsageTokens{PromptTokens: 1_000_000}, nowUTC()) |
| 57 | tab := &WorkspaceTab{ |
| 58 | ID: "billing", Ctrl: ctrl, |
| 59 | usageTelemetry: sessionUsageStats{CostLedger: ledger, SessionCost: 1, SessionCurrency: "USD", SessionCostComplete: true}, |
| 60 | } |
| 61 | app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID} |
| 62 | |
| 63 | if err := app.SetDesktopCurrency("CNY"); err != nil { |
| 64 | t.Fatalf("SetDesktopCurrency during active work: %v", err) |
| 65 | } |
| 66 | if tab.Ctrl != ctrl { |
| 67 | t.Fatal("display-only currency change replaced the controller") |
| 68 | } |
| 69 | quote := tab.telemetrySnapshot().Usage.SessionCostQuote |
| 70 | if quote == nil || quote.Selected == nil || quote.Selected.Currency != "CNY" || quote.Selected.Amount != "7" { |
| 71 | t.Fatalf("rebound quote = %+v, want selected CNY 7", quote) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestBalanceForTabRejectsPreviousSessionResponse(t *testing.T) { |
| 76 | isolateDesktopUserDirs(t) |
| 77 | ctrl := &billingRuntimeController{ |
| 78 | started: make(chan struct{}), |
| 79 | release: make(chan struct{}), |
| 80 | balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}}, |
| 81 | } |
| 82 | tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl} |
| 83 | tab.resetTelemetry("old-session.jsonl") |
| 84 | app := &App{ |
| 85 | ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, |
| 86 | tabOrder: []string{tab.ID}, activeTabID: tab.ID, |
| 87 | } |
| 88 | |
| 89 | done := make(chan BalanceInfo, 1) |
| 90 | go func() { done <- app.BalanceForTab(tab.ID) }() |
| 91 | <-ctrl.started |
| 92 | tab.resetTelemetry("new-session.jsonl") |
| 93 | close(ctrl.release) |
| 94 | <-done |
| 95 | |
| 96 | tab.telemMu.Lock() |
| 97 | display := tab.runtimeCostDisplayCurrency |
| 98 | tab.telemMu.Unlock() |
| 99 | if display != "" { |
| 100 | t.Fatalf("previous-session balance rebound new session to %q", display) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestBalanceForTabRejectsPreviousControllerResponse(t *testing.T) { |
| 105 | isolateDesktopUserDirs(t) |
| 106 | ctrl := &billingRuntimeController{ |
| 107 | started: make(chan struct{}), release: make(chan struct{}), |
| 108 | balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}}, |
| 109 | } |
| 110 | tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl} |
| 111 | app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID} |
| 112 | |
| 113 | done := make(chan BalanceInfo, 1) |
| 114 | go func() { done <- app.BalanceForTab(tab.ID) }() |
| 115 | <-ctrl.started |
| 116 | app.mu.Lock() |
| 117 | tab.Ctrl = &billingRuntimeController{} |
| 118 | app.mu.Unlock() |
| 119 | close(ctrl.release) |
| 120 | <-done |
| 121 | |
| 122 | tab.telemMu.Lock() |
| 123 | display := tab.runtimeCostDisplayCurrency |
| 124 | tab.telemMu.Unlock() |
| 125 | if display != "" { |
| 126 | t.Fatalf("previous-controller balance rebound current controller to %q", display) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestBalanceForTabRejectsResponseAfterExplicitCurrencyChange(t *testing.T) { |
| 131 | isolateDesktopUserDirs(t) |
| 132 | ctrl := &billingRuntimeController{ |
| 133 | started: make(chan struct{}), release: make(chan struct{}), |
| 134 | balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}}, |
| 135 | } |
| 136 | tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl} |
| 137 | app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID} |
| 138 | |
| 139 | done := make(chan BalanceInfo, 1) |
| 140 | go func() { done <- app.BalanceForTab(tab.ID) }() |
| 141 | <-ctrl.started |
| 142 | if err := app.SetDesktopCurrency("USD"); err != nil { |
| 143 | t.Fatalf("SetDesktopCurrency: %v", err) |
| 144 | } |
| 145 | close(ctrl.release) |
| 146 | <-done |
| 147 | |
| 148 | tab.telemMu.Lock() |
| 149 | display := tab.runtimeCostDisplayCurrency |
| 150 | tab.telemMu.Unlock() |
| 151 | if display != "" { |
| 152 | t.Fatalf("auto balance response overrode explicit USD with %q", display) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func TestBalanceForTabFailureDoesNotChangeDisplayBinding(t *testing.T) { |
| 157 | isolateDesktopUserDirs(t) |
| 158 | ctrl := &billingRuntimeController{err: errors.New("balance unavailable")} |
| 159 | tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl} |
| 160 | app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID} |
| 161 | |
| 162 | got := app.BalanceForTab(tab.ID) |
| 163 | if got.Err == "" { |
| 164 | t.Fatal("BalanceForTab error was not reported") |
| 165 | } |
| 166 | tab.telemMu.Lock() |
| 167 | display := tab.runtimeCostDisplayCurrency |
| 168 | tab.telemMu.Unlock() |
| 169 | if display != "" { |
| 170 | t.Fatalf("failed balance rebound display to %q", display) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func nowUTC() time.Time { return time.Now().UTC() } |
| 175 |