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
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"}},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user