Files
gogobee/internal/plugin/adventure_holidays_test.go
prosolis 0d3485c7c2 Add market plugin, arena, holidays, UNO stacking fix, and multiple UX improvements
- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback),
  Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands,
  exchange hours with DST support, 30-min room cooldown
- Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout,
  death lockout changed from 24h to midnight UTC across all code and flavor text
- Adventure holidays: double daily actions on holidays
- Adventure DM fix: 15-minute response window prevents bare numbers from triggering
  adventure during UNO games
- Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits
- Adventure revive: wire up adv_revived achievement on admin revive
- Column migration system for existing databases (holiday_action_taken)
- Fix italic markdown rendering after newlines
- Fix holdem DMs broken by space groups including DM rooms
- UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other)
- UNO multiplayer: add turn announcements after stack absorption and turn passes,
  jitter between rapid-fire messages with safe mutex handling
- Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus
- Market: guard against division by zero, nil pointer, and timezone errors
- README updates: plugin count 48→49, all new commands, feature flags, architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 21:26:19 -07:00

330 lines
8.7 KiB
Go

package plugin
import (
"strings"
"testing"
"time"
)
func TestEasterDate(t *testing.T) {
tests := []struct {
year int
month time.Month
day int
}{
{2024, time.March, 31},
{2025, time.April, 20},
{2026, time.April, 5},
{2027, time.March, 28},
{2028, time.April, 16},
}
for _, tc := range tests {
got := easterDate(tc.year)
want := time.Date(tc.year, tc.month, tc.day, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("Easter %d: got %s, want %s", tc.year, got.Format("2006-01-02"), want.Format("2006-01-02"))
}
}
}
func TestNthMonday(t *testing.T) {
tests := []struct {
desc string
year int
month time.Month
n int
want string
}{
{"Family Day 2026", 2026, time.February, 3, "2026-02-16"},
{"Labour Day 2026", 2026, time.September, 1, "2026-09-07"},
{"Thanksgiving CA 2026", 2026, time.October, 2, "2026-10-12"},
{"Family Day 2027", 2027, time.February, 3, "2027-02-15"},
}
for _, tc := range tests {
got := nthMonday(tc.year, tc.month, tc.n)
if got.Format("2006-01-02") != tc.want {
t.Errorf("%s: got %s, want %s", tc.desc, got.Format("2006-01-02"), tc.want)
}
}
}
func TestMondayBeforeMay25(t *testing.T) {
tests := []struct {
year int
want string
}{
{2026, "2026-05-18"},
{2027, "2027-05-24"},
{2028, "2028-05-22"},
}
for _, tc := range tests {
got := mondayBeforeMay25(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Victoria Day %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
// ── Hebrew Calendar Tests ───────────────────────────────────────────────────
func TestHebrewRoshHashanah(t *testing.T) {
// Verified against hebcal.com
tests := []struct {
year int
want string
}{
{2023, "2023-09-16"}, // 5784
{2024, "2024-10-03"}, // 5785
{2025, "2025-09-23"}, // 5786
{2026, "2026-09-12"}, // 5787
{2027, "2027-10-02"}, // 5788
{2028, "2028-09-21"}, // 5789
{2030, "2030-09-28"}, // 5791
}
for _, tc := range tests {
got := hebrewRoshHashanah(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Rosh Hashanah %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
func TestHebrewYomKippur(t *testing.T) {
tests := []struct {
year int
want string
}{
{2026, "2026-09-21"},
{2027, "2027-10-11"},
}
for _, tc := range tests {
got := hebrewYomKippur(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Yom Kippur %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
func TestHebrewPassover(t *testing.T) {
// 15 Nisan (first full day of Pesach)
tests := []struct {
year int
want string
}{
{2024, "2024-04-23"},
{2025, "2025-04-13"},
{2026, "2026-04-02"},
{2027, "2027-04-22"},
}
for _, tc := range tests {
got := hebrewPassover(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Passover %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
func TestHebrewHanukkah(t *testing.T) {
// 25 Kislev (first full day)
tests := []struct {
year int
want string
}{
{2024, "2024-12-26"},
{2025, "2025-12-15"},
{2026, "2026-12-05"},
{2027, "2027-12-25"},
}
for _, tc := range tests {
got := hebrewHanukkah(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Hanukkah %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
// ── Tabular Islamic Calendar Tests ──────────────────────────────────────────
func TestIslamicEidAlFitr(t *testing.T) {
// Tabular Islamic calendar — ±1 day from observed
tests := []struct {
year int
want string
}{
{2026, "2026-03-20"},
{2027, "2027-03-10"},
}
for _, tc := range tests {
dates := tabularIslamicDates(tc.year, 10, 1)
if len(dates) == 0 {
t.Errorf("Eid al-Fitr %d: no dates returned", tc.year)
continue
}
got := dates[0].Format("2006-01-02")
if got != tc.want {
t.Errorf("Eid al-Fitr %d: got %s, want %s", tc.year, got, tc.want)
}
}
}
func TestIslamicEidAlAdha(t *testing.T) {
tests := []struct {
year int
want string
}{
{2026, "2026-05-27"},
{2027, "2027-05-17"}, // Tabular Islamic calendar ±1 day from observed
}
for _, tc := range tests {
dates := tabularIslamicDates(tc.year, 12, 10)
if len(dates) == 0 {
t.Errorf("Eid al-Adha %d: no dates returned", tc.year)
continue
}
got := dates[0].Format("2006-01-02")
if got != tc.want {
t.Errorf("Eid al-Adha %d: got %s, want %s", tc.year, got, tc.want)
}
}
}
// ── Integration Tests ───────────────────────────────────────────────────────
func TestFloatingHolidays_AllComputed(t *testing.T) {
for year := 2024; year <= 2035; year++ {
holidays := floatingHolidays(year)
if len(holidays) < 14 { // at least: 4 Easter + Nowruz + 4 Canadian + 4 Hebrew + 2 Islamic
t.Errorf("year %d: only %d floating holidays, expected at least 14", year, len(holidays))
}
// Verify no zero-time dates (would indicate a computation failure)
for _, h := range holidays {
if h.Date.IsZero() {
t.Errorf("year %d: holiday %q has zero date", year, h.Name)
}
if h.Date.Year() != year {
t.Errorf("year %d: holiday %q falls in %d", year, h.Name, h.Date.Year())
}
}
}
}
func TestFloatingHolidays_DateCollisions(t *testing.T) {
holidays := floatingHolidays(2026)
seen := make(map[string][]string)
for _, h := range holidays {
key := h.Date.Format("2006-01-02")
seen[key] = append(seen[key], h.Name)
}
for date, names := range seen {
if len(names) > 1 {
t.Logf("NOTE: %d holidays on %s: %s", len(names), date, strings.Join(names, ", "))
}
}
}
func TestFixedHolidays_Christmas(t *testing.T) {
found := false
for _, h := range majorHolidays {
if h.Month == 12 && h.Day == 25 {
found = true
break
}
}
if !found {
t.Error("Christmas not in majorHolidays")
}
}
func TestFixedHolidays_NoDuplicateMonthDay(t *testing.T) {
seen := make(map[[2]int]string)
for _, h := range majorHolidays {
key := [2]int{h.Month, h.Day}
if prev, ok := seen[key]; ok {
t.Errorf("duplicate fixed holiday on %d/%d: %s and %s", h.Month, h.Day, prev, h.Name)
}
seen[key] = h.Name
}
}
func TestHolidaySecondPromptRender(t *testing.T) {
char := &AdventureCharacter{
DisplayName: "TestPlayer",
CombatLevel: 5,
MiningSkill: 3,
ForagingSkill: 2,
}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 1, Condition: 100, Name: "Iron Sword"},
}
bonuses := &AdvBonusSummary{}
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
if !strings.Contains(text, "Action 1 complete") {
t.Error("second prompt should mention action 1 complete")
}
if !strings.Contains(text, "second action") {
t.Error("second prompt should mention second action")
}
if !strings.Contains(text, "Dungeon") {
t.Error("second prompt should list Dungeon option")
}
}
func TestDailySummary_HolidayBlock(t *testing.T) {
players := []AdvPlayerDaySummary{
{DisplayName: "Alice", Activity: "dungeon", Location: "Cellar", Outcome: "success", LootValue: 500, HolidayActions: 2},
{DisplayName: "Bob", Activity: "mine", Location: "Quarry", Outcome: "success", LootValue: 300, HolidayActions: 1},
{DisplayName: "Carol", IsDead: true, DeadUntil: "14:00 UTC", Activity: "dungeon", Location: "Cave", Outcome: "death", HolidayActions: 1},
}
text := renderAdvDailySummary("2026-12-25", nil, TwinBeeRewardSummary{}, players, "Christmas")
if !strings.Contains(text, "Christmas") {
t.Error("summary should contain holiday name")
}
if !strings.Contains(text, "two actions today") {
t.Error("summary should mention two actions on holidays")
}
if !strings.Contains(text, "before their second action") {
t.Error("summary should note Carol died before second action")
}
// Without holiday
textNoHol := renderAdvDailySummary("2026-12-26", nil, TwinBeeRewardSummary{}, players, "")
if strings.Contains(textNoHol, "two actions") {
t.Error("non-holiday summary should NOT mention two actions")
}
}
// ── JDN Roundtrip Test ──────────────────────────────────────────────────────
func TestGregorianFromJDN_Roundtrip(t *testing.T) {
// Verify known JDN ↔ Gregorian conversions
tests := []struct {
jdn int
date string
}{
{2451545, "2000-01-01"}, // J2000.0
{2440588, "1970-01-01"}, // Unix epoch
{2299161, "1582-10-15"}, // Gregorian calendar adoption
}
for _, tc := range tests {
got := gregorianFromJDN(tc.jdn)
if got.Format("2006-01-02") != tc.date {
t.Errorf("JDN %d: got %s, want %s", tc.jdn, got.Format("2006-01-02"), tc.date)
}
}
}