All checks were successful
CI / build (push) Successful in 48s
- Add /setcalendar command for calendar type parity (Settings UI) - Add 17 store tests covering all repository methods - Add handler tests for formatDuration, fmtHHMM, fmtDDHHMM - Add domain tests for BurnoutLevel.String, NavMonth, FormatSalary - Fix silent error discarding in save* helpers (log write failures) - Fix computeAndAwardXP error handling instead of discarding - Wrap critical store.go SQL errors with fmt.Errorf context - Update README commands table with /setcalendar
372 lines
8.9 KiB
Go
372 lines
8.9 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"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", "$"},
|
|
{"\u20AC EUR", "\u20AC"},
|
|
{"\u00A3", "\u00A3"},
|
|
{"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) {
|
|
got := ComputeDailySalary(3600, 25.0)
|
|
if got != 25.0 {
|
|
t.Errorf("ComputeDailySalary(3600, 25) = %.2f, want 25.00", got)
|
|
}
|
|
|
|
got = ComputeDailySalary(9000, 20.0)
|
|
if got != 50.0 {
|
|
t.Errorf("ComputeDailySalary(9000, 20) = %.2f, want 50.00", got)
|
|
}
|
|
|
|
got = ComputeDailySalary(0, 50.0)
|
|
if got != 0 {
|
|
t.Errorf("ComputeDailySalary(0, 50) = %.2f, want 0", got)
|
|
}
|
|
|
|
got = ComputeDailySalary(3599, 10.0)
|
|
if got != 10.0 {
|
|
t.Errorf("ComputeDailySalary(3599, 10) = %.2f, want 10.00", got)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeNote(t *testing.T) {
|
|
if got := SanitizeNote(" hello "); got != "hello" {
|
|
t.Errorf("SanitizeNote trim: got %q", got)
|
|
}
|
|
|
|
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
|
|
t.Errorf("SanitizeNote control: got %q", got)
|
|
}
|
|
|
|
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
|
|
t.Errorf("SanitizeNote newline/tab: got %q", got)
|
|
}
|
|
|
|
long := make([]byte, 600)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
got := SanitizeNote(string(long))
|
|
if len(got) > 500 {
|
|
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
|
|
}
|
|
}
|
|
|
|
func TestBurnoutLevelString(t *testing.T) {
|
|
cases := []struct {
|
|
level BurnoutLevel
|
|
want string
|
|
}{
|
|
{BurnoutHealthy, "Healthy"},
|
|
{BurnoutMild, "Mild concern"},
|
|
{BurnoutModerate, "Moderate concern"},
|
|
{BurnoutHigh, "High concern"},
|
|
{BurnoutLevel(99), "Unknown"},
|
|
}
|
|
for _, c := range cases {
|
|
got := c.level.String()
|
|
if got != c.want {
|
|
t.Errorf("BurnoutLevel(%d).String() = %q, want %q", c.level, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNavMonth(t *testing.T) {
|
|
tests := []struct {
|
|
y, m int
|
|
prevY, prevM int
|
|
nextY, nextM int
|
|
}{
|
|
{2026, 6, 2026, 5, 2026, 7},
|
|
{2026, 1, 2025, 12, 2026, 2},
|
|
{2026, 12, 2026, 11, 2027, 1},
|
|
{2024, 2, 2024, 1, 2024, 3},
|
|
}
|
|
for _, tc := range tests {
|
|
prevY, prevM, nextY, nextM := NavMonth(tc.y, tc.m)
|
|
if prevY != tc.prevY || prevM != tc.prevM {
|
|
t.Errorf("NavMonth(%d, %d) prev = (%d, %d), want (%d, %d)", tc.y, tc.m, prevY, prevM, tc.prevY, tc.prevM)
|
|
}
|
|
if nextY != tc.nextY || nextM != tc.nextM {
|
|
t.Errorf("NavMonth(%d, %d) next = (%d, %d), want (%d, %d)", tc.y, tc.m, nextY, nextM, tc.nextY, tc.nextM)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFormatSalary(t *testing.T) {
|
|
tests := []struct {
|
|
est SalaryEstimate
|
|
currency string
|
|
want string
|
|
wantShort string
|
|
}{
|
|
{SalaryEstimate{GrossEarning: 1000.50}, "$ USD", "$1000.50 USD", "$1000.50"},
|
|
{SalaryEstimate{GrossEarning: 0}, "$ USD", "$0.00 USD", "$0.00"},
|
|
{SalaryEstimate{GrossEarning: 2500}, "T Toman", "T2500.00 Toman", "T2500.00"},
|
|
}
|
|
for _, tc := range tests {
|
|
got := tc.est.FormatSalary(tc.currency)
|
|
if got != tc.want {
|
|
t.Errorf("FormatSalary(%q) = %q, want %q", tc.currency, got, tc.want)
|
|
}
|
|
gotShort := tc.est.FormatSalaryShort(tc.currency)
|
|
if gotShort != tc.wantShort {
|
|
t.Errorf("FormatSalaryShort(%q) = %q, want %q", tc.currency, gotShort, tc.wantShort)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
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},
|
|
{"\u20AC EUR", "\u20AC 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComputeStreakFromDates_Empty(t *testing.T) {
|
|
cur, longest := ComputeStreakFromDates(nil, "2026-06-25")
|
|
if cur != 0 || longest != 0 {
|
|
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
|
|
}
|
|
}
|
|
|
|
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
|
|
dates := []string{"2026-06-25", "2026-06-26"}
|
|
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
|
|
if cur != 2 {
|
|
t.Errorf("current streak = %d, want 2", cur)
|
|
}
|
|
if longest != 2 {
|
|
t.Errorf("longest streak = %d, want 2", longest)
|
|
}
|
|
}
|
|
|
|
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
|
|
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
|
|
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
|
|
if cur != 2 {
|
|
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
|
|
}
|
|
if longest != 2 {
|
|
t.Errorf("longest streak = %d, want 2", longest)
|
|
}
|
|
}
|
|
|
|
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
|
|
dates := []string{"2026-06-24", "2026-06-25"}
|
|
cur, longest := ComputeStreakFromDates(dates, "2026-06-26")
|
|
if cur != 0 {
|
|
t.Errorf("current streak = %d, want 0 (no events today)", cur)
|
|
}
|
|
if longest != 2 {
|
|
t.Errorf("longest streak = %d, want 2", longest)
|
|
}
|
|
}
|
|
|
|
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
|
|
dates := make([]string, 0, 10)
|
|
for d := 1; d <= 10; d++ {
|
|
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
|
|
}
|
|
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
|
|
if cur != 10 {
|
|
t.Errorf("current streak = %d, want 10", cur)
|
|
}
|
|
if longest != 10 {
|
|
t.Errorf("longest streak = %d, want 10", longest)
|
|
}
|
|
}
|
|
|
|
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
|
|
dates := []string{}
|
|
for d := 1; d <= 5; d++ {
|
|
dates = append(dates, formatDateInt(2026, 6, d))
|
|
}
|
|
dates = append(dates, "2026-06-09", "2026-06-10")
|
|
cur, longest := ComputeStreakFromDates(dates, "2026-06-10")
|
|
if cur != 2 {
|
|
t.Errorf("current streak = %d, want 2", cur)
|
|
}
|
|
if longest != 5 {
|
|
t.Errorf("longest streak = %d, want 5", longest)
|
|
}
|
|
}
|
|
|
|
func formatDateInt(y, m, d int) string {
|
|
return FormatDateForCalendar(
|
|
func() string {
|
|
s := make([]byte, 10)
|
|
s[0] = byte('0' + y/1000)
|
|
s[1] = byte('0' + (y/100)%10)
|
|
s[2] = byte('0' + (y/10)%10)
|
|
s[3] = byte('0' + y%10)
|
|
s[4] = '-'
|
|
s[5] = byte('0' + m/10)
|
|
s[6] = byte('0' + m%10)
|
|
s[7] = '-'
|
|
s[8] = byte('0' + d/10)
|
|
s[9] = byte('0' + d%10)
|
|
return string(s)
|
|
}(), "gregorian")
|
|
}
|