返回 DeepSeek-Reasonix
billing_runtime_test.go
根目录 / desktop / billing_runtime_test.go
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
175 lines GO