diff --git a/app.go b/app.go index 226e818..fab150f 100644 --- a/app.go +++ b/app.go @@ -253,17 +253,20 @@ type Notification struct { // Balances stored per service are CUMULATIVE lifetime totals; the summary turns // them into a single display-currency total plus per-day accrual figures. type EarningsSummary struct { - DisplayCurrency string `json:"displayCurrency"` - Total float64 `json:"total"` - Today float64 `json:"today"` - Month float64 `json:"month"` - TodayChange float64 `json:"todayChange"` - MonthChange float64 `json:"monthChange"` - Breakdown []ServiceEarning `json:"breakdown"` - Points []PointsBalance `json:"points"` - Daily []DailyPoint `json:"daily"` - RatesStale bool `json:"ratesStale"` - RatesUpdated string `json:"ratesUpdated"` + DisplayCurrency string `json:"displayCurrency"` + Total float64 `json:"total"` + // TotalKnown is false when Total is 0 only because nothing could be priced. + // Absent is not zero: the UI must render an em dash, never a fabricated 0. + TotalKnown bool `json:"totalKnown"` + Today float64 `json:"today"` + Month float64 `json:"month"` + TodayChange float64 `json:"todayChange"` + MonthChange float64 `json:"monthChange"` + Breakdown []ServiceEarning `json:"breakdown"` + Points []PointsBalance `json:"points"` + Daily []DailyPoint `json:"daily"` + RatesStale bool `json:"ratesStale"` + RatesUpdated string `json:"ratesUpdated"` } // ServiceEarning is one service's latest balance, both native and converted to @@ -524,6 +527,9 @@ func (a *App) computeEarningsSummary(earnings []store.EarningsRecord) EarningsSu // rate outage or a zero rate) is dropped from BOTH and flags the rates stale, // so it is never mislabeled as a reward point. var total float64 + // priced / unpriced separate "nothing to add up" from "nothing could be + // converted" -- see the TotalKnown assignment below. + priced, unpriced := 0, 0 latestDay := "" for plat, days := range daysByPlat { if len(days) == 0 { @@ -546,11 +552,26 @@ func (a *App) computeEarningsSummary(earnings []store.EarningsRecord) EarningsSu } if conv, ok := a.exchange.ToDisplay(bal, cur, disp); ok { total += conv + priced++ continue } + unpriced++ summary.RatesStale = true } summary.Total = total + // A total of 0 means two completely different things and the UI cannot tell + // them apart from the number alone: a user who has genuinely earned nothing, + // and a user whose balances could not be priced at all. The second is an + // UNKNOWN total, not a zero one -- and it is not exotic, because ToDisplay + // routes through USD, so one missing DISPLAY-currency rate makes every + // platform unpriceable at once. Reporting 0 there tells a user with real + // money that they have none. + // + // Known when something was actually priced, or when there was nothing to + // price in the first place (a new install really is at zero). A partial sum + // stays known and is flagged stale, because an understated real figure is + // still a measurement. + summary.TotalKnown = priced > 0 || unpriced == 0 today := dayStr(0) if latestDay == "" { diff --git a/earnings_total_known_test.go b/earnings_total_known_test.go new file mode 100644 index 0000000..e8f5860 --- /dev/null +++ b/earnings_total_known_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GeiserX/CashPilot-Desktop/internal/catalog" + "github.com/GeiserX/CashPilot-Desktop/internal/config" + "github.com/GeiserX/CashPilot-Desktop/internal/exchange" + "github.com/GeiserX/CashPilot-Desktop/internal/store" +) + +// summaryFor builds a real App against an in-memory store and an exchange service +// wired to httptest feeds, then returns the computed summary. fiatRates is the +// literal Frankfurter `rates` object, so a test can withhold a currency to model a +// rate outage rather than mocking the exchange package. +func summaryFor(t *testing.T, display string, fiatRates string, seed []store.EarningsRecord) EarningsSummary { + t.Helper() + t.Setenv("CASHPILOT_DESKTOP_DATA_DIR", t.TempDir()) + cfg, err := config.NewManager() + if err != nil { + t.Fatalf("config.NewManager error: %v", err) + } + if err := cfg.Update(func(c *config.AppConfig) { c.DisplayCurrency = display }); err != nil { + t.Fatalf("cfg.Update error: %v", err) + } + st, err := store.Open(cfg.DataDir()) + if err != nil { + t.Fatalf("store.Open error: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + cat, err := catalog.LoadEmbedded(serviceFiles) + if err != nil { + t.Fatalf("catalog.LoadEmbedded error: %v", err) + } + + cg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"mysterium":{"usd":0.25}}`) + })) + t.Cleanup(cg.Close) + fr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"amount":1,"base":"USD","rates":`+fiatRates+`}`) + })) + t.Cleanup(fr.Close) + svc := exchange.NewService( + exchange.WithBaseURLs(cg.URL, fr.URL), + exchange.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), + exchange.WithCryptoIDs(map[string]string{"MYST": "mysterium"}), + ) + if err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("exchange refresh error: %v", err) + } + for _, r := range seed { + if _, err := st.SaveEarnings(r); err != nil { + t.Fatalf("SaveEarnings(%+v) error: %v", r, err) + } + } + app := &App{cfg: cfg, store: st, catalog: cat, exchange: svc, ctx: context.Background()} + return app.computeEarningsSummary(app.store.ListLatestEarnings()) +} + +func daysAgoTS(daysAgo, hour int) string { + d := time.Now().UTC().AddDate(0, 0, -daysAgo) + return time.Date(d.Year(), d.Month(), d.Day(), hour, 0, 0, 0, time.UTC).Format(time.RFC3339) +} + +// TestTotalKnownSeparatesUnpriceableFromZero pins the distinction the dashboard +// headline depends on: a total of 0 is a real measurement for a user who has +// earned nothing, and a fabrication for a user whose balances could not be +// priced. Both produce Total == 0, so the number alone cannot be trusted and +// TotalKnown is what the UI must branch on. +// +// The unpriceable case is not exotic. ToDisplay routes every balance through +// USD, so a single missing DISPLAY-currency rate -- one failed fiat fetch -- +// makes every platform unpriceable at once, for every non-USD user. +func TestTotalKnownSeparatesUnpriceableFromZero(t *testing.T) { + realMoney := []store.EarningsRecord{ + {Platform: "honeygain", Balance: 1.00, Currency: "USD", CreatedAt: daysAgoTS(1, 10)}, + {Platform: "honeygain", Balance: 250.00, Currency: "USD", CreatedAt: daysAgoTS(0, 10)}, + } + + tests := []struct { + name string + display string + fiatRates string + seed []store.EarningsRecord + wantKnown bool + wantStale bool + wantTotalGT float64 // require Total strictly greater; -1 to require exactly 0 + }{ + { + // The defect. 250.00 USD of real earnings, no JPY rate, so every + // contribution is dropped and Total falls back to Go's zero value. + name: "display currency has no rate", + display: "JPY", + fiatRates: `{"EUR":0.9}`, + seed: realMoney, + wantKnown: false, + wantStale: true, + wantTotalGT: -1, + }, + { + // Control: same money, a display currency that IS priced. + name: "display currency is priced", + display: "EUR", + fiatRates: `{"EUR":0.9}`, + seed: realMoney, + wantKnown: true, + wantStale: false, + wantTotalGT: 0, + }, + { + // The case that stops the fix from over-reaching: a brand new + // install genuinely IS at zero, and that zero must stay known or + // every new user is told their total is unavailable. + name: "no earnings at all is a real zero", + display: "USD", + fiatRates: `{"EUR":0.9}`, + seed: nil, + wantKnown: true, + wantStale: false, + wantTotalGT: -1, + }, + { + // A zero that was actually PRICED. Distinct from the case above: + // that one is known via `unpriced == 0` and never converts + // anything, so without this the `priced > 0` half of the rule is + // only ever exercised by non-zero totals. + name: "a priced balance that is genuinely zero", + display: "USD", + fiatRates: `{"EUR":0.9}`, + seed: []store.EarningsRecord{ + {Platform: "honeygain", Balance: 0, Currency: "USD", CreatedAt: daysAgoTS(1, 10)}, + {Platform: "honeygain", Balance: 0, Currency: "USD", CreatedAt: daysAgoTS(0, 10)}, + }, + wantKnown: true, + wantStale: false, + wantTotalGT: -1, + }, + { + // A partial sum stays KNOWN and is flagged stale. An understated + // real figure is still a measurement, and blanking it would throw + // away the priced services to describe the unpriced one. + name: "one platform unpriceable, another priced", + display: "USD", + fiatRates: `{"EUR":0.9}`, + seed: append(append([]store.EarningsRecord{}, realMoney...), + store.EarningsRecord{Platform: "nosana", Balance: 5, Currency: "NOS", CreatedAt: daysAgoTS(0, 10)}), + wantKnown: true, + wantStale: true, + wantTotalGT: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sum := summaryFor(t, tc.display, tc.fiatRates, tc.seed) + if sum.TotalKnown != tc.wantKnown { + t.Errorf("TotalKnown = %v, want %v (Total=%v, RatesStale=%v)", + sum.TotalKnown, tc.wantKnown, sum.Total, sum.RatesStale) + } + if sum.RatesStale != tc.wantStale { + t.Errorf("RatesStale = %v, want %v", sum.RatesStale, tc.wantStale) + } + if tc.wantTotalGT < 0 { + if sum.Total != 0 { + t.Errorf("Total = %v, want exactly 0", sum.Total) + } + } else if sum.Total <= tc.wantTotalGT { + t.Errorf("Total = %v, want > %v", sum.Total, tc.wantTotalGT) + } + }) + } +} + +// TestTotalKnownFalseHidesRealMoney is the one that states the user-visible +// consequence outright, so a future change that "simplifies" TotalKnown away +// fails against the symptom rather than the mechanism: the summary reports a +// total of zero while the breakdown still holds 250.00 of real balance. +func TestTotalKnownFalseHidesRealMoney(t *testing.T) { + sum := summaryFor(t, "JPY", `{"EUR":0.9}`, []store.EarningsRecord{ + {Platform: "honeygain", Balance: 1.00, Currency: "USD", CreatedAt: daysAgoTS(1, 10)}, + {Platform: "honeygain", Balance: 250.00, Currency: "USD", CreatedAt: daysAgoTS(0, 10)}, + }) + + if sum.Total != 0 { + t.Fatalf("precondition: Total = %v, want 0 (the unpriceable case)", sum.Total) + } + var found bool + for _, b := range sum.Breakdown { + if b.Platform == "honeygain" { + found = true + if b.Balance != 250.00 { + t.Errorf("breakdown balance = %v, want 250.00", b.Balance) + } + } + } + if !found { + t.Fatal("honeygain missing from breakdown") + } + if sum.TotalKnown { + t.Error("TotalKnown = true while a total of 0 sits above 250.00 of real balance; " + + "the dashboard would state zero as a measured fact") + } +} diff --git a/frontend/package.json b/frontend/package.json index 93e1d7c..c7050a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "build": "tsc && vite build", "preview": "vite preview", "harness:build": "tsc -p tsconfig.harness.json", - "test": "npm run harness:build && node scripts/fleet_render_check.mjs && node scripts/earnings_render_check.mjs && node scripts/status_render_check.mjs" + "test": "npm run harness:build && node scripts/fleet_render_check.mjs && node scripts/earnings_render_check.mjs && node scripts/status_render_check.mjs && node scripts/total_render_check.mjs" }, "dependencies": {}, "devDependencies": { diff --git a/frontend/scripts/total_render_check.mjs b/frontend/scripts/total_render_check.mjs new file mode 100644 index 0000000..7d417e6 --- /dev/null +++ b/frontend/scripts/total_render_check.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Browser-free checks on the headline/topbar total. CashPilot-Desktop-0mb. +// +// The rule, again, and this time at the most-read number in the product: +// +// NOT MEASURED is not the same claim as MEASURED ZERO. +// +// Every balance is priced by routing through USD, so ONE missing +// display-currency rate makes every platform unpriceable at once. The backend +// then has nothing to add up and `total` keeps its zero value. Rendering that +// as "$0.00" tells a user with real money that they have none -- and it does it +// directly above a breakdown that still lists the money, so the page contradicts +// itself. +// +// `totalKnown` is what separates the two. It must be explicitly true; false, +// missing, or a summary that never loaded are all UNKNOWN. +// +// node scripts/total_render_check.mjs # against ./.harness-build + +import { totalText, totalCaption, totalIsKnown, UNKNOWN_TOTAL } from "../.harness-build/render/total.js"; + +let failures = 0; +let checks = 0; + +function check(name, condition, detail = "") { + checks++; + if (condition) return; + failures++; + console.error(`FAIL ${name}${detail ? `\n ${detail}` : ""}`); +} + +// --------------------------------------------------------------------------- +// THE RULE. +// --------------------------------------------------------------------------- +const unpriceable = { total: 0, totalKnown: false, ratesStale: true }; + +check( + "THE RULE: an unpriceable total renders the em dash, never a formatted zero", + totalText(unpriceable, "JPY") === UNKNOWN_TOTAL, + `got: ${JSON.stringify(totalText(unpriceable, "JPY"))}` +); +check( + "and it contains no digit at all, so no currency formatter can leak a 0 through", + !/\d/.test(totalText(unpriceable, "JPY")), + `got: ${JSON.stringify(totalText(unpriceable, "JPY"))}` +); + +// A genuine zero is a real measurement and must still be shown as one -- +// otherwise every brand new user is told their total is unavailable. +const genuineZero = { total: 0, totalKnown: true, ratesStale: false }; +check( + "a GENUINE zero still renders as a number, not the em dash", + totalText(genuineZero, "USD") !== UNKNOWN_TOTAL && /0/.test(totalText(genuineZero, "USD")), + `got: ${JSON.stringify(totalText(genuineZero, "USD"))}` +); + +// The two cases produce the SAME total. If the renderer ever branched on the +// number instead of the flag, this pair could not both hold. +check( + "the two cases are distinguished despite carrying an identical total of 0", + unpriceable.total === genuineZero.total && + totalText(unpriceable, "USD") !== totalText(genuineZero, "USD"), + `unpriceable=${JSON.stringify(totalText(unpriceable, "USD"))} genuine=${JSON.stringify(totalText(genuineZero, "USD"))}` +); + +// --------------------------------------------------------------------------- +// Absent is not true. +// --------------------------------------------------------------------------- +check( + "a summary with NO totalKnown field is unknown, not assumed good", + totalText({ total: 250 }, "USD") === UNKNOWN_TOTAL, + `got: ${JSON.stringify(totalText({ total: 250 }, "USD"))}` +); +check( + "a null summary is unknown", + totalText(null, "USD") === UNKNOWN_TOTAL +); +check( + "an undefined summary is unknown", + totalText(undefined, "USD") === UNKNOWN_TOTAL +); +check( + "totalKnown must be the boolean true -- a truthy 1 does not qualify", + !totalIsKnown({ total: 5, totalKnown: 1 }) +); + +// --------------------------------------------------------------------------- +// A flag saying "known" is not enough -- there must actually be a total. +// formatBalance coerces a non-finite value to 0, so these would each render a +// confident zero and reintroduce the very bug this module removes. +// --------------------------------------------------------------------------- +check( + "totalKnown:true with NO total renders the em dash, not a zero", + totalText({ totalKnown: true }, "USD") === UNKNOWN_TOTAL, + `got: ${JSON.stringify(totalText({ totalKnown: true }, "USD"))}` +); +check( + "totalKnown:true with NaN renders the em dash", + totalText({ totalKnown: true, total: NaN }, "USD") === UNKNOWN_TOTAL, + `got: ${JSON.stringify(totalText({ totalKnown: true, total: NaN }, "USD"))}` +); +check( + "totalKnown:true with Infinity renders the em dash", + totalText({ totalKnown: true, total: Infinity }, "USD") === UNKNOWN_TOTAL, + `got: ${JSON.stringify(totalText({ totalKnown: true, total: Infinity }, "USD"))}` +); +check( + "totalKnown:true with a STRING total renders the em dash", + totalText({ totalKnown: true, total: "250" }, "USD") === UNKNOWN_TOTAL, + `got: ${JSON.stringify(totalText({ totalKnown: true, total: "250" }, "USD"))}` +); +check( + "but a real 0 is finite and still renders as a number", + totalText({ totalKnown: true, total: 0 }, "USD") !== UNKNOWN_TOTAL +); + +// --------------------------------------------------------------------------- +// A known total is formatted normally. +// --------------------------------------------------------------------------- +const priced = { total: 250, totalKnown: true, ratesStale: false }; +check( + "a known total renders the real figure", + /250/.test(totalText(priced, "USD")), + `got: ${JSON.stringify(totalText(priced, "USD"))}` +); + +// --------------------------------------------------------------------------- +// The caption has to explain the em dash, or it reads as a bug. +// --------------------------------------------------------------------------- +check( + "the unknown caption says the balances could not be priced", + /could not be priced/i.test(totalCaption(unpriceable)), + `got: ${JSON.stringify(totalCaption(unpriceable))}` +); +check( + "the unknown caption does NOT reuse 'stale' -- stale means slightly old, not absent", + !/stale/i.test(totalCaption(unpriceable)), + `got: ${JSON.stringify(totalCaption(unpriceable))}` +); +check( + "a known-but-stale total keeps the weaker stale wording", + /stale/i.test(totalCaption({ total: 250, totalKnown: true, ratesStale: true })), + `got: ${JSON.stringify(totalCaption({ total: 250, totalKnown: true, ratesStale: true }))}` +); +check( + "a known, fresh total gets the plain caption", + totalCaption(priced) === "Across convertible services", + `got: ${JSON.stringify(totalCaption(priced))}` +); + +console.log(`${checks - failures}/${checks} total-render checks passed`); +if (failures) process.exit(1); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 744622c..52611f1 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -34,6 +34,7 @@ import { renderHealthBadge } from "./render/health"; import { renderMystNodes } from "./render/myst"; import { renderEarningBreakdown } from "./render/earnings"; import { escapeHtml, formatBalance } from "./render/format"; +import { totalText, totalCaption } from "./render/total"; import type { AppState, BackgroundStatus, DailyPoint, Deployment, FleetState, HealthScore, InstallGuide, PointsBalance, Service, SettingsState } from "./wails"; let state: AppState | null = null; @@ -218,7 +219,6 @@ function renderDashboard(current: AppState) { const summary = current.summary; const disp = summary?.displayCurrency || current.config.displayCurrency || "USD"; const runningCount = deployments.filter((dep) => dep.status === "running").length; - const total = summary?.total ?? 0; const daily = summary?.daily || []; const breakdown = summary?.breakdown || []; const points = summary?.points || []; @@ -228,10 +228,10 @@ function renderDashboard(current: AppState) {