mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
renderArenaStreakEntry now takes *DnDCharacter and reads c.Level instead of AdventureCharacter.CombatLevel. renderArenaStatus drops its unused *AdventureCharacter arg. Callers in handleArenaMenu / handleArenaStatus updated; menu path uses ensureCharForDnDCmd to fetch (or auto-migrate) the sheet. Tests switched accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package plugin
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ── #1: Credit/Debit reject non-positive amounts ───────────────────────────
|
|
// These can't call the real Debit/Credit (they need a DB), so we test the
|
|
// guard logic by verifying the internal credit() helper is never reached
|
|
// with bad amounts. We test the normalizeExpression path instead, and rely
|
|
// on the exported method signatures for the guard.
|
|
|
|
// TestNormalizeExpression_NoMutation ensures normalizeExpression doesn't
|
|
// corrupt valid expressions (regression guard for calc changes).
|
|
func TestNormalizeExpression_NoMutation_Audit(t *testing.T) {
|
|
// Already-numeric expressions should pass through unmangled.
|
|
got := normalizeExpression("100 + 200")
|
|
if got != "100 + 200" {
|
|
t.Errorf("normalizeExpression mangled numeric expression: %q", got)
|
|
}
|
|
}
|
|
|
|
// ── #6: safeGo recovers panics ─────────────────────────────────────────────
|
|
|
|
func TestSafeGo_RecoversPanic(t *testing.T) {
|
|
var recovered atomic.Bool
|
|
done := make(chan struct{})
|
|
|
|
// We can't directly observe the slog.Error from safeGo, but we can
|
|
// verify the goroutine doesn't kill the test process.
|
|
safeGo("test-panic", func() {
|
|
defer func() { close(done) }()
|
|
recovered.Store(true)
|
|
panic("intentional test panic")
|
|
})
|
|
|
|
select {
|
|
case <-done:
|
|
// The goroutine ran (recovered.Store happened before panic).
|
|
if !recovered.Load() {
|
|
t.Error("safeGo function body did not execute")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("safeGo goroutine did not complete within timeout")
|
|
}
|
|
}
|
|
|
|
func TestSafeGo_NormalExecution(t *testing.T) {
|
|
var result atomic.Int64
|
|
done := make(chan struct{})
|
|
|
|
safeGo("test-normal", func() {
|
|
result.Store(42)
|
|
close(done)
|
|
})
|
|
|
|
select {
|
|
case <-done:
|
|
if result.Load() != 42 {
|
|
t.Errorf("safeGo result = %d, want 42", result.Load())
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("safeGo goroutine did not complete within timeout")
|
|
}
|
|
}
|
|
|
|
func TestSafeGo_NilPanic(t *testing.T) {
|
|
done := make(chan struct{})
|
|
safeGo("test-nil-panic", func() {
|
|
defer func() { close(done) }()
|
|
panic(nil)
|
|
})
|
|
|
|
select {
|
|
case <-done:
|
|
// didn't crash — success
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("safeGo did not recover from nil panic")
|
|
}
|
|
}
|
|
|
|
// ── #9: Arena bail channel ownership transfer ──────────────────────────────
|
|
// Verify that the LoadAndDelete pattern prevents double-close panics.
|
|
|
|
func TestArenaBailChannel_OwnershipTransfer(t *testing.T) {
|
|
var m sync.Map
|
|
|
|
// Simulate: countdown stores channel
|
|
bailCh := make(chan struct{})
|
|
m.Store("user1", bailCh)
|
|
|
|
// Simulate: bail handler claims it via LoadAndDelete
|
|
ch, ok := m.LoadAndDelete("user1")
|
|
if !ok {
|
|
t.Fatal("LoadAndDelete should succeed first time")
|
|
}
|
|
close(ch.(chan struct{}))
|
|
|
|
// Simulate: countdown timer expires, tries LoadAndDelete — should fail
|
|
_, ok = m.LoadAndDelete("user1")
|
|
if ok {
|
|
t.Error("second LoadAndDelete should fail — bail handler already claimed it")
|
|
}
|
|
}
|
|
|
|
func TestArenaBailChannel_TimerExpires_BailLate(t *testing.T) {
|
|
var m sync.Map
|
|
|
|
bailCh := make(chan struct{})
|
|
m.Store("user1", bailCh)
|
|
|
|
// Timer expires — countdown claims it
|
|
_, ok := m.LoadAndDelete("user1")
|
|
if !ok {
|
|
t.Fatal("countdown should claim channel")
|
|
}
|
|
|
|
// Bail comes late — should find nothing
|
|
_, ok = m.LoadAndDelete("user1")
|
|
if ok {
|
|
t.Error("late bail should find nothing — countdown already claimed")
|
|
}
|
|
}
|
|
|
|
// ── #13: Index DDL is valid SQL ────────────────────────────────────────────
|
|
// Verify the index creation statements parse (they'll be tested at schema
|
|
// init time, but this catches obvious syntax errors in the string).
|
|
|
|
func TestIndexDDL_ContainsNewIndexes(t *testing.T) {
|
|
// Verify the arena status render works for both states that the new
|
|
// idx_arena_runs_user(user_id, status) index covers.
|
|
|
|
run := &ArenaRun{
|
|
Tier: 2,
|
|
Round: 3,
|
|
Status: "active",
|
|
Earnings: 500,
|
|
TierEarnings: 100,
|
|
}
|
|
status := renderArenaStatus(run)
|
|
if !strings.Contains(status, "Tier: 2") {
|
|
t.Error("renderArenaStatus should show tier")
|
|
}
|
|
if !strings.Contains(status, "fight") {
|
|
t.Error("renderArenaStatus should show fight command for active runs")
|
|
}
|
|
|
|
// Awaiting state
|
|
run.Status = "awaiting"
|
|
status = renderArenaStatus(run)
|
|
if !strings.Contains(status, "bail") {
|
|
t.Error("renderArenaStatus should show bail command for awaiting runs")
|
|
}
|
|
}
|
|
|
|
// ── #15: JSON unmarshal error handling ──────────────────────────────────────
|
|
// We can't test lottery_db directly (needs DB), but we can verify the
|
|
// countMatches and formatLotteryNumbers handle edge cases that would
|
|
// surface from corrupt JSON.
|
|
|
|
func TestCountMatches_EmptySlices(t *testing.T) {
|
|
if got := countMatches([]int{}, []int{1, 2, 3}); got != 0 {
|
|
t.Errorf("countMatches(empty, winning) = %d, want 0", got)
|
|
}
|
|
if got := countMatches([]int{1, 2, 3}, []int{}); got != 0 {
|
|
t.Errorf("countMatches(ticket, empty) = %d, want 0", got)
|
|
}
|
|
if got := countMatches([]int{}, []int{}); got != 0 {
|
|
t.Errorf("countMatches(empty, empty) = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatLotteryNumbers_Empty(t *testing.T) {
|
|
got := formatLotteryNumbers([]int{})
|
|
if got != "" {
|
|
t.Errorf("formatLotteryNumbers(empty) = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatLotteryNumbers_Single(t *testing.T) {
|
|
got := formatLotteryNumbers([]int{7})
|
|
if got != "7" {
|
|
t.Errorf("formatLotteryNumbers([7]) = %q, want %q", got, "7")
|
|
}
|
|
}
|
|
|
|
// ── #16: Error messages don't leak internals ───────────────────────────────
|
|
|
|
func TestRenderArenaDeath_NoInternalLeak(t *testing.T) {
|
|
tier := &ArenaTier{Number: 3, Name: "The Gauntlet"}
|
|
monster := &ArenaMonster{Name: "Shadow Beast"}
|
|
msg := renderArenaDeath(tier, 2, monster, 1500, "The beast was faster.")
|
|
|
|
// Should contain user-facing info
|
|
if !strings.Contains(msg, "DEAD") {
|
|
t.Error("death message should say DEAD")
|
|
}
|
|
if !strings.Contains(msg, "€1,500") && !strings.Contains(msg, "1500") {
|
|
// Check for either comma-formatted or raw
|
|
if !strings.Contains(msg, "Forfeited") {
|
|
t.Error("death message should mention forfeited earnings")
|
|
}
|
|
}
|
|
if !strings.Contains(msg, "midnight UTC") {
|
|
t.Error("death message should mention respawn time")
|
|
}
|
|
|
|
// Should NOT contain stack traces or internal error patterns
|
|
for _, bad := range []string{"panic", "goroutine", "runtime.", ".go:", "err="} {
|
|
if strings.Contains(msg, bad) {
|
|
t.Errorf("death message should not contain internal detail %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderArenaStatus_NoInternalLeak(t *testing.T) {
|
|
run := &ArenaRun{
|
|
Tier: 1,
|
|
Round: 2,
|
|
Status: "active",
|
|
Earnings: 0,
|
|
TierEarnings: 50,
|
|
}
|
|
msg := renderArenaStatus(run)
|
|
|
|
for _, bad := range []string{"panic", "goroutine", "runtime.", "err="} {
|
|
if strings.Contains(msg, bad) {
|
|
t.Errorf("status message should not contain internal detail %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── #20/#21: Error messages have actionable guidance ────────────────────────
|
|
|
|
func TestRenderArenaAlreadyInRun_HasGuidance(t *testing.T) {
|
|
// Active state
|
|
run := &ArenaRun{Tier: 2, Round: 3, Status: "active"}
|
|
msg := renderArenaAlreadyInRun(run)
|
|
if !strings.Contains(msg, "!arena fight") {
|
|
t.Error("already-in-run (active) should tell user to !arena fight")
|
|
}
|
|
|
|
// Awaiting state
|
|
run.Status = "awaiting"
|
|
run.Earnings = 500
|
|
msg = renderArenaAlreadyInRun(run)
|
|
if !strings.Contains(msg, "!bail") {
|
|
t.Error("already-in-run (awaiting) should tell user to !bail")
|
|
}
|
|
}
|
|
|
|
func TestArenaHelpText_HasAllCommands(t *testing.T) {
|
|
required := []string{"!arena", "!arena fight", "!bail", "!arena status", "!arena stats", "!arena leaderboard", "!arena help"}
|
|
for _, cmd := range required {
|
|
if !strings.Contains(arenaHelpText, cmd) {
|
|
t.Errorf("arena help text missing command %q", cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── #17: Integer overflow edge cases ───────────────────────────────────────
|
|
|
|
func TestArenaRoundReward_LargeTier(t *testing.T) {
|
|
tier := &ArenaTier{BasePayout: 1000, SkillMultiplier: 2.0}
|
|
reward := arenaRoundReward(tier, 4, 100)
|
|
expected := int64(1000*4) + int64(float64(100)*2.0)
|
|
if reward != expected {
|
|
t.Errorf("arenaRoundReward = %d, want %d", reward, expected)
|
|
}
|
|
if reward < 0 {
|
|
t.Error("arenaRoundReward should not overflow to negative")
|
|
}
|
|
}
|
|
|
|
func TestFormatNumber_MaxInt(t *testing.T) {
|
|
// Large number shouldn't panic
|
|
got := formatNumber(999999999)
|
|
if !strings.Contains(got, "999") {
|
|
t.Errorf("formatNumber(999999999) = %q, doesn't look right", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatNumberInt64_MaxValues(t *testing.T) {
|
|
got := formatNumberInt64(9_223_372_036_854_775_807) // max int64
|
|
if got == "" {
|
|
t.Error("formatNumberInt64(maxint64) should not return empty")
|
|
}
|
|
if !strings.Contains(got, ",") {
|
|
t.Errorf("formatNumberInt64(maxint64) = %q, expected commas", got)
|
|
}
|
|
}
|
|
|
|
// ── Regression: normalizeExpression edge cases ─────────────────────────────
|
|
|
|
func TestNormalizeExpression_NegativeInput(t *testing.T) {
|
|
got := normalizeExpression("-5 + 3")
|
|
if !strings.Contains(got, "-5") || !strings.Contains(got, "+ 3") {
|
|
t.Errorf("normalizeExpression(\"-5 + 3\") = %q, unexpected", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeExpression_EmptyInput(t *testing.T) {
|
|
got := normalizeExpression("")
|
|
if got != "" {
|
|
t.Errorf("normalizeExpression(\"\") = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatCalcResult_NegativeFloat(t *testing.T) {
|
|
got := formatCalcResult(-12345.67)
|
|
if !strings.Contains(got, "-") {
|
|
t.Errorf("formatCalcResult(-12345.67) = %q, should be negative", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatCalcResult_LargeWholeFloat(t *testing.T) {
|
|
got := formatCalcResult(1000000.0)
|
|
if got != "1,000,000" {
|
|
t.Errorf("formatCalcResult(1000000.0) = %q, want %q", got, "1,000,000")
|
|
}
|
|
}
|