refactor: break up large functions, add RPG/league/salary/burnout, fix bugs, add tests

- Refactor GenerateMonthlyReport (198→70), HandleCallback (128→85),
  handleHistoryCallback (131→38), handleExportCallback (100→25),
  checkAchievements (86→25) into extracted helper functions
- Add RPG system (XP, levels, streak, achievements, burnout)
- Add WorkTime League leaderboard
- Add salary estimation with configurable currency/rate
- Add input sanitization (SanitizeNote, SanitizeDisplayName)
- Add settings select-style pickers for toggles (report, RPG, league)
- Add break threshold inline picker UI
- Add event notes with pending state and /note command
- Add multi-calendar support (Jalali, Hijri)
- Add Excel export with theme picker
- Fix: getTodayBreakThreshold uses user's timezone (was UTC)
- Fix: acknowledgeCallback passes real callback ID
- Fix: currency symbol safety with currencySymbol() helper
- Fix: count work hours in summary on day-off days
- Fix: unsilence all error returns from SQL Exec/Bot API/migrations
- Remove stale db/schema.sql and unreferenced computeWeekTotals
- Extract constants: DateLayout, TimeLayout, secondsPerHour, etc.
- Add 12 new tests (XP, salary, sanitize, currency, dateutil)
- Remove duplicate package doc comment in totals.go
This commit is contained in:
2026-06-25 01:02:16 +03:30
parent 0d942c51db
commit 344c615666
18 changed files with 2510 additions and 483 deletions

173
internal/bot/rpg_test.go Normal file
View File

@@ -0,0 +1,173 @@
package bot
import (
"testing"
)
func TestXpForLevel(t *testing.T) {
cases := []struct {
n int
want int64
}{
{0, 0},
{1, 100},
{2, 300},
{5, 1500},
{10, 5500},
{27, 37800},
{50, 127500},
}
for _, c := range cases {
got := xpForLevel(c.n)
if got != c.want {
t.Errorf("xpForLevel(%d) = %d, want %d", c.n, got, c.want)
}
}
}
func TestLevelForXP(t *testing.T) {
cases := []struct {
xp int64
want int
}{
{0, 0},
{50, 0},
{99, 0},
{100, 1},
{299, 1},
{300, 2},
{5500, 10},
{37800, 27},
{127500, 50},
}
for _, c := range cases {
got := levelForXP(c.xp)
if got != c.want {
t.Errorf("levelForXP(%d) = %d, want %d", c.xp, got, c.want)
}
}
}
func TestLevelForXP_Monotonic(t *testing.T) {
for n := 0; n <= 100; n++ {
xp := xpForLevel(n)
l := levelForXP(xp)
if l != n {
t.Errorf("levelForXP(xpForLevel(%d)) = %d, want %d", n, l, n)
}
}
}
func TestXpForWorkSeconds(t *testing.T) {
if got := xpForWorkSeconds(0); got != 0 {
t.Errorf("xpForWorkSeconds(0) = %d, want 0", got)
}
if got := xpForWorkSeconds(3600); got != 3600 {
t.Errorf("xpForWorkSeconds(3600) = %d, want 3600", got)
}
if got := xpForWorkSeconds(1800); got != 1800 {
t.Errorf("xpForWorkSeconds(1800) = %d, want 1800", got)
}
}
func TestStreakBonus(t *testing.T) {
cases := []struct {
streak int
want int64
}{
{0, 0},
{1, 50},
{10, 500},
{5, 250},
{20, 500},
}
for _, c := range cases {
got := streakBonus(c.streak)
if got != c.want {
t.Errorf("streakBonus(%d) = %d, want %d", c.streak, got, c.want)
}
}
}
func TestCurrencySymbol(t *testing.T) {
cases := []struct {
currency string
want string
}{
{"", "$"},
{"$", "$"},
{"$ USD", "$"},
{"€ EUR", "€"},
{"£", "£"},
{"Toman", "Toman"},
{" ", "$"},
}
for _, c := range cases {
got := currencySymbol(c.currency)
if got != c.want {
t.Errorf("currencySymbol(%q) = %q, want %q", c.currency, got, c.want)
}
}
}
func TestComputeDailySalary(t *testing.T) {
// 1 hour at $25/hr
got := computeDailySalary(3600, 25.0)
if got != 25.0 {
t.Errorf("computeDailySalary(3600, 25) = %.2f, want 25.00", got)
}
// 2.5 hours at $20/hr = $50
got = computeDailySalary(9000, 20.0)
if got != 50.0 {
t.Errorf("computeDailySalary(9000, 20) = %.2f, want 50.00", got)
}
// 0 seconds = $0
got = computeDailySalary(0, 50.0)
if got != 0 {
t.Errorf("computeDailySalary(0, 50) = %.2f, want 0", got)
}
// rounding: 3599 sec (0.9997 hr) at $10/hr = $9.997 -> $10.00
got = computeDailySalary(3599, 10.0)
if got != 10.0 {
t.Errorf("computeDailySalary(3599, 10) = %.2f, want 10.00", got)
}
}
func TestSanitizeNote(t *testing.T) {
// Trims spaces
if got := SanitizeNote(" hello "); got != "hello" {
t.Errorf("SanitizeNote trim: got %q", got)
}
// Removes control chars except newline/tab
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
t.Errorf("SanitizeNote control: got %q", got)
}
// Preserves newlines and tabs
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
t.Errorf("SanitizeNote newline/tab: got %q", got)
}
// Truncates long notes
long := string(make([]byte, 600))
for i := range long {
long = long[:i] + "a" + long[i+1:]
}
got := SanitizeNote(long)
if len(got) > 500 {
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
}
}
func TestSanitizeDisplayName(t *testing.T) {
if got := SanitizeDisplayName(" Alice "); got != "Alice" {
t.Errorf("SanitizeDisplayName trim: got %q", got)
}
if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" {
t.Errorf("SanitizeDisplayName control: got %q", got)
}
}