fix: computeTrend division by zero, add tests for trend and currency parsing, add username to settings
This commit is contained in:
@@ -212,11 +212,37 @@ func computeTrend(store *db.Store, userID int64, today string, loc *time.Locatio
|
||||
recentAvg /= 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 {
|
||||
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 {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -170,4 +171,57 @@ func TestSanitizeDisplayName(t *testing.T) {
|
||||
if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"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))
|
||||
}
|
||||
|
||||
// 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>.
|
||||
func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) {
|
||||
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))
|
||||
return
|
||||
}
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) < 2 {
|
||||
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")
|
||||
currency, errMsg := parseCurrencyInput(args)
|
||||
if errMsg != "" {
|
||||
h.sendText(ctx, msg.Chat.ID, errMsg)
|
||||
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)
|
||||
if err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||
|
||||
@@ -192,6 +192,9 @@ func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, brea
|
||||
leagueStatus = "on"
|
||||
}
|
||||
rows := [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"},
|
||||
},
|
||||
{
|
||||
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
|
||||
{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("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("Currency: %s", st.Currency), CallbackData: "currency"},
|
||||
},
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user