fix: computeTrend division by zero, add tests for trend and currency parsing, add username to settings

This commit is contained in:
2026-06-25 01:58:27 +03:30
parent 88ccc6aa68
commit 85587d563d
4 changed files with 104 additions and 18 deletions

View File

@@ -212,11 +212,37 @@ func computeTrend(store *db.Store, userID int64, today string, loc *time.Locatio
recentAvg /= 3.0 recentAvg /= 3.0
olderAvg /= 3.0 olderAvg /= 3.0
if olderAvg < 1 {
if recentAvg > 0 {
return 15, "new"
}
return 5, "stable"
}
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
if recentAvg > olderAvg*1.2 { if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", math.Round((recentAvg-olderAvg)/math.Max(olderAvg, 1)*100)) return 15, fmt.Sprintf("+%.0f%%", changePct)
} }
if olderAvg > recentAvg*1.2 { if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("-%.0f%%", math.Round((olderAvg-recentAvg)/math.Max(olderAvg, 1)*100)) return 0, fmt.Sprintf("%.0f%%", changePct)
}
return 5, "stable"
}
// computeTrendFromAvgs is a pure version of the trend computation for testing.
func computeTrendFromAvgs(recentAvg, olderAvg float64) (pts int, direction string) {
if olderAvg < 1 {
if recentAvg > 0 {
return 15, "new"
}
return 5, "stable"
}
changePct := math.Round((recentAvg - olderAvg) / olderAvg * 100)
if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", changePct)
}
if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("%.0f%%", changePct)
} }
return 5, "stable" return 5, "stable"
} }

View File

@@ -1,6 +1,7 @@
package bot package bot
import ( import (
"strings"
"testing" "testing"
) )
@@ -170,4 +171,57 @@ func TestSanitizeDisplayName(t *testing.T) {
if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" { if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" {
t.Errorf("SanitizeDisplayName control: got %q", got) t.Errorf("SanitizeDisplayName control: got %q", got)
} }
if got := SanitizeDisplayName(""); got != "" {
t.Errorf("SanitizeDisplayName empty: got %q", got)
}
}
func TestParseCurrencyInput(t *testing.T) {
cases := []struct {
input string
want string
ok bool
}{
{"$ USD", "$ USD", true},
{"T Toman", "T Toman", true},
{"IRR Rial", "IRR Rial", true},
{"€ EUR", "€ EUR", true},
{"", "", false},
{"$", "", false},
{" ", "", false},
{"$$$ TooLongCode", "", false},
}
for _, c := range cases {
got, errMsg := parseCurrencyInput(c.input)
if c.ok && (got != c.want || errMsg != "") {
t.Errorf("parseCurrencyInput(%q) = (%q, %q), want (%q, \"\")", c.input, got, errMsg, c.want)
}
if !c.ok && errMsg == "" {
t.Errorf("parseCurrencyInput(%q) = (%q, \"\"), want error", c.input, got)
}
}
}
func TestComputeTrendFromAvgs(t *testing.T) {
cases := []struct {
recent, older float64
wantPts int
wantDirSub string
}{
{0, 0, 5, "stable"},
{100, 0, 15, "new"},
{0, 100, 0, "-100"},
{121, 100, 15, "+21"},
{100, 100, 5, "stable"},
{100, 130, 0, "-23"},
}
for _, c := range cases {
pts, dir := computeTrendFromAvgs(c.recent, c.older)
if pts != c.wantPts {
t.Errorf("computeTrendFromAvgs(%.0f, %.0f) pts = %d, want %d", c.recent, c.older, pts, c.wantPts)
}
if !strings.Contains(dir, c.wantDirSub) {
t.Errorf("computeTrendFromAvgs(%.0f, %.0f) dir = %q, want containing %q", c.recent, c.older, dir, c.wantDirSub)
}
}
} }

View File

@@ -6,6 +6,7 @@ import (
"math" "math"
"strings" "strings"
"time" "time"
"unicode"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
@@ -179,6 +180,20 @@ func (h *Handler) handleSetRateMsg(ctx context.Context, msg *models.Message) {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate)) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate))
} }
// parseCurrencyInput validates and normalizes currency input (symbol + code).
func parseCurrencyInput(s string) (string, string) {
parts := strings.Fields(s)
if len(parts) < 2 {
return "", "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial"
}
for _, p := range parts[:2] {
if len(p) > 10 || strings.ContainsFunc(p, unicode.IsControl) {
return "", "Invalid currency. Use short printable strings like $ USD, T Toman, IRR Rial."
}
}
return parts[0] + " " + parts[1], ""
}
// handleSetCurrencyMsg handles /setcurrency <symbol> <code>. // handleSetCurrencyMsg handles /setcurrency <symbol> <code>.
func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) { func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency")) args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
@@ -192,20 +207,11 @@ func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial", st.Currency)) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial", st.Currency))
return return
} }
parts := strings.Fields(args) currency, errMsg := parseCurrencyInput(args)
if len(parts) < 2 { if errMsg != "" {
h.sendText(ctx, msg.Chat.ID, "Please provide both symbol and code.\nUsage: /setcurrency <symbol> <code>\nExamples:\n/setcurrency $ USD\n/setcurrency T Toman\n/setcurrency IRR Rial") h.sendText(ctx, msg.Chat.ID, errMsg)
return return
} }
if len(parts[0]) > 10 || strings.ContainsFunc(parts[0], func(r rune) bool { return r < 32 || r > 126 }) {
h.sendText(ctx, msg.Chat.ID, "Invalid currency symbol. Use a short printable string like $, €, £.")
return
}
if len(parts[1]) > 10 || strings.ContainsFunc(parts[1], func(r rune) bool { return r < 32 || r > 126 }) {
h.sendText(ctx, msg.Chat.ID, "Invalid currency code.")
return
}
currency := parts[0] + " " + parts[1]
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")

View File

@@ -192,6 +192,9 @@ func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, brea
leagueStatus = "on" leagueStatus = "on"
} }
rows := [][]models.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
{
{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"},
},
{ {
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}, {Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}, {Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"},
@@ -208,12 +211,9 @@ func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, brea
{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}, {Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"},
{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}, {Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"},
}, },
{
{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
},
{ {
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}, {Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
}, },
{{Text: "Back to Menu", CallbackData: "back_menu"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}},
} }