From 7312dcf7eaaad3b182347df429602e4884bc15e5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 8 Aug 2026 22:40:03 +0700 Subject: [PATCH] feat(amlich): add Vietnamese lunar calendar conversion module /amlich converts duong lich to am lich (defaults to today, Asia/Saigon); /duonglich converts am lich to duong lich with a nhuan flag for leap months. Dates accept d, d/m, or d/m/yyyy - missing parts fill from today in the input's calendar. Conversion is a dependency-free port of Ho Ngoc Duc's algorithm at UTC+7, with rejection of impossible lunar inputs, anchored by known Tet/leap-month dates and a 1950-2050 round-trip test. --- README.md | 1 + cmd/server/command_menu_test.go | 2 + cmd/server/main.go | 2 + internal/modules/amlich/amlich.go | 20 ++ internal/modules/amlich/handlers.go | 154 +++++++++++++ internal/modules/amlich/handlers_test.go | 223 +++++++++++++++++++ internal/modules/amlich/lunar.go | 264 +++++++++++++++++++++++ internal/modules/amlich/lunar_test.go | 147 +++++++++++++ 8 files changed, 813 insertions(+) create mode 100644 internal/modules/amlich/amlich.go create mode 100644 internal/modules/amlich/handlers.go create mode 100644 internal/modules/amlich/handlers_test.go create mode 100644 internal/modules/amlich/lunar.go create mode 100644 internal/modules/amlich/lunar_test.go diff --git a/README.md b/README.md index a1677b2..366d64e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Atlas via long polling and an in-process cron scheduler. |---|---| | `util` | `/help`, `/info`, `/stickerid` | | `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | +| `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | | `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`) + daily push | diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index a81a002..38e6318 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -58,6 +58,8 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { } expectedParameters := map[string]string{ + "amlich": "[date]", + "duonglich": " [nhuan]", "coin_price": "", "coin_topup": "", "coin_buy": " ", diff --git a/cmd/server/main.go b/cmd/server/main.go index bf6bd7b..7acf76e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "github.com/tiennm99/miti99bot/internal/log" "github.com/tiennm99/miti99bot/internal/metrics" "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/amlich" "github.com/tiennm99/miti99bot/internal/modules/coin" "github.com/tiennm99/miti99bot/internal/modules/gold" "github.com/tiennm99/miti99bot/internal/modules/lol" @@ -81,6 +82,7 @@ func factories() map[string]modules.Factory { return map[string]modules.Factory{ "util": util.New, "misc": misc.New, + "amlich": amlich.New, "monkeyd": monkeyd.New, "wordle": wordle.New, "loldle": loldle.New, diff --git a/internal/modules/amlich/amlich.go b/internal/modules/amlich/amlich.go new file mode 100644 index 0000000..d79f7c1 --- /dev/null +++ b/internal/modules/amlich/amlich.go @@ -0,0 +1,20 @@ +// Package amlich converts dates between dương lịch (the Gregorian calendar) +// and the Vietnamese âm lịch (lunisolar calendar): /amlich and /duonglich. +// Conversions are pure calendar math computed at UTC+7 — the offset the +// Vietnamese calendar is defined against. +package amlich + +import ( + "github.com/tiennm99/miti99bot/internal/modules" +) + +// New is the module Factory. The module is stateless — pure calendar math with +// no store access — so Deps is unused. +func New(modules.Deps) modules.Module { + return modules.Module{ + Commands: []modules.Command{ + amlichCommand(), + duonglichCommand(), + }, + } +} diff --git a/internal/modules/amlich/handlers.go b/internal/modules/amlich/handlers.go new file mode 100644 index 0000000..8eeb33a --- /dev/null +++ b/internal/modules/amlich/handlers.go @@ -0,0 +1,154 @@ +package amlich + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +// saigonLocation pins "today" for /amlich without an argument. FixedZone (not +// LoadLocation) matches the rest of the repo and needs no tzdata in the +// runtime image; Vietnam has no DST so the fixed offset is always correct. +var saigonLocation = time.FixedZone("Asia/Saigon", 7*60*60) + +const amlichUsage = `Cách dùng: /amlich [dd[/mm[/yyyy]]] — đổi dương lịch sang âm lịch, bỏ trống để xem hôm nay; thiếu tháng/năm thì dùng tháng/năm hiện tại. Ví dụ: /amlich 29/01/2025` + +const duonglichUsage = `Cách dùng: /duonglich [nhuan] — đổi âm lịch sang dương lịch, thiếu tháng/năm thì dùng tháng/năm âm lịch hiện tại; thêm "nhuan" nếu là tháng nhuận. Ví dụ: /duonglich 01/01/2025` + +// yearRangeMessage is shared by both commands; the bound lives in lunar.go +// next to the algorithm it protects. +var yearRangeMessage = fmt.Sprintf("Chỉ hỗ trợ các năm từ %d đến %d.", minLunarYear, maxLunarYear) + +func amlichCommand() modules.Command { + return modules.Command{ + Name: "amlich", + Visibility: modules.VisibilityPublic, + Description: "Đổi ngày dương lịch sang âm lịch (bỏ trống: hôm nay)", + Parameters: "[date]", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + now := time.Now().In(saigonLocation) + var day, month, year int + arg := strings.TrimSpace(chathelper.ArgAfterCommand(update.Message.Text)) + if arg == "" { + day, month, year = now.Day(), int(now.Month()), now.Year() + } else { + var ok bool + day, month, year, ok = parseDate(arg, int(now.Month()), now.Year()) + if !ok || !isValidSolarDate(day, month, year) { + return chathelper.Reply(ctx, b, update.Message, amlichUsage) + } + } + if year < minLunarYear || year > maxLunarYear { + return chathelper.Reply(ctx, b, update.Message, yearRangeMessage) + } + lunarDay, lunarMonth, lunarYear, leap := solarToLunar(day, month, year) + text := fmt.Sprintf("Dương lịch %02d/%02d/%d là ngày %d tháng %d%s năm %s %d âm lịch.", + day, month, year, lunarDay, lunarMonth, leapLabel(leap), canChiYear(lunarYear), lunarYear) + return chathelper.Reply(ctx, b, update.Message, text) + }, + } +} + +func duonglichCommand() modules.Command { + return modules.Command{ + Name: "duonglich", + Visibility: modules.VisibilityPublic, + Description: "Đổi ngày âm lịch sang dương lịch", + Parameters: " [nhuan]", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + fields := strings.Fields(chathelper.ArgAfterCommand(update.Message.Text)) + if len(fields) == 0 || len(fields) > 2 { + return chathelper.Reply(ctx, b, update.Message, duonglichUsage) + } + // Missing month/year fall back to today's date expressed in the + // same calendar as the input — the current lunar month and year. + now := time.Now().In(saigonLocation) + _, nowLunarMonth, nowLunarYear, _ := solarToLunar(now.Day(), int(now.Month()), now.Year()) + day, month, year, ok := parseDate(fields[0], nowLunarMonth, nowLunarYear) + // Lunar months never exceed 30 days; deeper validity (day 30 of a + // 29-day month, leap-month existence) is checked by lunarToSolar. + if !ok || month < 1 || month > 12 || day < 1 || day > 30 { + return chathelper.Reply(ctx, b, update.Message, duonglichUsage) + } + leap := false + if len(fields) == 2 { + switch strings.ToLower(fields[1]) { + case "nhuan", "nhuận": + leap = true + default: + return chathelper.Reply(ctx, b, update.Message, duonglichUsage) + } + } + if year < minLunarYear || year > maxLunarYear { + return chathelper.Reply(ctx, b, update.Message, yearRangeMessage) + } + solarDay, solarMonth, solarYear, err := lunarToSolar(day, month, year, leap) + if err != nil { + return chathelper.Reply(ctx, b, update.Message, "Không đổi được: "+err.Error()+".") + } + text := fmt.Sprintf("Âm lịch ngày %d tháng %d%s năm %s %d là %02d/%02d/%d dương lịch.", + day, month, leapLabel(leap), canChiYear(year), year, solarDay, solarMonth, solarYear) + return chathelper.Reply(ctx, b, update.Message, text) + }, + } +} + +// parseDate parses "d", "d/m", or "d/m/yyyy" (leading zeros optional, digits +// only); a missing month or year is filled from defMonth/defYear — today in +// the calendar the caller works in. It checks numeric shape only; calendar +// validity is the caller's job because solar and lunar dates have different +// rules. +func parseDate(s string, defMonth, defYear int) (day, month, year int, ok bool) { + parts := strings.Split(s, "/") + if len(parts) < 1 || len(parts) > 3 { + return 0, 0, 0, false + } + nums := make([]int, 0, 3) + for _, part := range parts { + if part == "" || len(part) > 4 { + return 0, 0, 0, false + } + for _, r := range part { + if r < '0' || r > '9' { + return 0, 0, 0, false + } + } + n, err := strconv.Atoi(part) + if err != nil { + return 0, 0, 0, false + } + nums = append(nums, n) + } + day, month, year = nums[0], defMonth, defYear + if len(nums) >= 2 { + month = nums[1] + } + if len(nums) == 3 { + year = nums[2] + } + return day, month, year, true +} + +// isValidSolarDate reports whether the Gregorian date exists, using +// time.Date's normalization (31/02 rolls into March → mismatch → invalid). +func isValidSolarDate(day, month, year int) bool { + if month < 1 || month > 12 || day < 1 { + return false + } + t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) + return t.Day() == day && int(t.Month()) == month && t.Year() == year +} diff --git a/internal/modules/amlich/handlers_test.go b/internal/modules/amlich/handlers_test.go new file mode 100644 index 0000000..1335900 --- /dev/null +++ b/internal/modules/amlich/handlers_test.go @@ -0,0 +1,223 @@ +package amlich + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// installAmlich wires the amlich module to a recording bot. The module is +// stateless, so no store is needed. +func installAmlich(t *testing.T) *testutil.RecordingBot { + t.Helper() + rb := testutil.NewRecordingBot(t) + mod := New(modules.Deps{}) + + reg := &modules.Registry{ + Modules: []modules.Module{{Name: "amlich", Commands: mod.Commands}}, + AllCommands: map[string]modules.Command{}, + } + for _, c := range mod.Commands { + reg.AllCommands[c.Name] = c + } + modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: 999}) + return rb +} + +func TestNew_RegistersExpectedCommands(t *testing.T) { + mod := New(modules.Deps{}) + + want := map[string]modules.Visibility{ + "amlich": modules.VisibilityPublic, + "duonglich": modules.VisibilityPublic, + } + if len(mod.Commands) != len(want) { + t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want)) + } + for _, c := range mod.Commands { + v, ok := want[c.Name] + if !ok { + t.Errorf("unexpected command %q", c.Name) + continue + } + if c.Visibility != v { + t.Errorf("command %q visibility = %d, want %d", c.Name, c.Visibility, v) + } + if c.Handler == nil { + t.Errorf("command %q has nil handler", c.Name) + } + } +} + +func TestAmlich_ConvertsKnownDate(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/amlich 29/01/2025")) + + want := "Dương lịch 29/01/2025 là ngày 1 tháng 1 năm Ất Tỵ 2025 âm lịch." + if got := rb.LastSent().Text(); got != want { + t.Errorf("reply = %q, want %q", got, want) + } +} + +func TestAmlich_LeapMonth(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/amlich 22/03/2023")) + + got := rb.LastSent().Text() + if !strings.Contains(got, "tháng 2 nhuận") || !strings.Contains(got, "Quý Mão") { + t.Errorf("reply = %q, want leap month 2 of Quý Mão", got) + } +} + +func TestAmlich_DefaultsToToday(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/amlich")) + + got := rb.LastSent().Text() + if !strings.HasPrefix(got, "Dương lịch ") || !strings.Contains(got, "âm lịch") { + t.Errorf("no-arg reply = %q, want a conversion of today", got) + } +} + +func TestParseDate_PartialDatesFillDefaults(t *testing.T) { + cases := []struct { + in string + d, m, y int + ok bool + }{ + {"25", 25, 6, 2026, true}, + {"25/8", 25, 8, 2026, true}, + {"25/08/2027", 25, 8, 2027, true}, + {"05", 5, 6, 2026, true}, + {"", 0, 0, 0, false}, + {"25/8/2027/9", 0, 0, 0, false}, + {"2a", 0, 0, 0, false}, + {"25//2027", 0, 0, 0, false}, + {"-5", 0, 0, 0, false}, + {"25/8/20270", 0, 0, 0, false}, + } + for _, tc := range cases { + d, m, y, ok := parseDate(tc.in, 6, 2026) + if d != tc.d || m != tc.m || y != tc.y || ok != tc.ok { + t.Errorf("parseDate(%q, 6, 2026) = %d/%d/%d ok=%v, want %d/%d/%d ok=%v", + tc.in, d, m, y, ok, tc.d, tc.m, tc.y, tc.ok) + } + } +} + +// replyFor drives one command through a fresh bot and returns the reply text. +func replyFor(t *testing.T, text string) string { + t.Helper() + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + return rb.LastSent().Text() +} + +// Partial dates must answer exactly like their fully spelled-out equivalent. +// Expectations are computed from the same clock the handler uses, so the tests +// hold on any date (a midnight flip between the two reads is the only, and +// vanishingly unlikely, race). +func TestAmlich_PartialDateFillsCurrentMonthYear(t *testing.T) { + now := time.Now().In(saigonLocation) + full := replyFor(t, fmt.Sprintf("/amlich 15/%d/%d", int(now.Month()), now.Year())) + if got := replyFor(t, "/amlich 15"); got != full { + t.Errorf("/amlich 15 = %q, want %q", got, full) + } + if got := replyFor(t, fmt.Sprintf("/amlich 15/%d", int(now.Month()))); got != full { + t.Errorf("/amlich 15/m = %q, want %q", got, full) + } +} + +func TestDuonglich_PartialDateFillsCurrentLunarMonthYear(t *testing.T) { + now := time.Now().In(saigonLocation) + _, lunarMonth, lunarYear, _ := solarToLunar(now.Day(), int(now.Month()), now.Year()) + full := replyFor(t, fmt.Sprintf("/duonglich 10/%d/%d", lunarMonth, lunarYear)) + if got := replyFor(t, "/duonglich 10"); got != full { + t.Errorf("/duonglich 10 = %q, want %q", got, full) + } + if got := replyFor(t, fmt.Sprintf("/duonglich 10/%d", lunarMonth)); got != full { + t.Errorf("/duonglich 10/m = %q, want %q", got, full) + } +} + +func TestAmlich_InvalidInputRepliesUsage(t *testing.T) { + for _, text := range []string{ + "/amlich foo", + "/amlich 31/02/2025", // nonexistent Gregorian date + "/amlich 1-1-2025", + "/amlich 01/13/2025", + } { + t.Run(text, func(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != amlichUsage { + t.Errorf("reply = %q, want usage", got) + } + }) + } +} + +func TestAmlich_YearOutOfRange(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/amlich 01/01/1500")) + + if got := rb.LastSent().Text(); got != yearRangeMessage { + t.Errorf("reply = %q, want year-range message", got) + } +} + +func TestDuonglich_ConvertsKnownDate(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/duonglich 01/01/2025")) + + want := "Âm lịch ngày 1 tháng 1 năm Ất Tỵ 2025 là 29/01/2025 dương lịch." + if got := rb.LastSent().Text(); got != want { + t.Errorf("reply = %q, want %q", got, want) + } +} + +func TestDuonglich_LeapMonth(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/duonglich 01/02/2023 nhuan")) + + got := rb.LastSent().Text() + if !strings.Contains(got, "tháng 2 nhuận") || !strings.Contains(got, "22/03/2023") { + t.Errorf("reply = %q, want leap month 2/2023 → 22/03/2023", got) + } +} + +func TestDuonglich_RejectsWrongLeapMonth(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/duonglich 01/03/2023 nhuan")) + + got := rb.LastSent().Text() + if !strings.Contains(got, "không có tháng 3 nhuận") { + t.Errorf("reply = %q, want leap-month rejection", got) + } +} + +func TestDuonglich_InvalidInputRepliesUsage(t *testing.T) { + for _, text := range []string{ + "/duonglich", + "/duonglich foo", + "/duonglich 31/01/2025", // lunar day > 30 + "/duonglich 01/13/2025", + "/duonglich 01/01/2025 xyz", + "/duonglich 01/01/2025 nhuan extra", + } { + t.Run(text, func(t *testing.T) { + rb := installAmlich(t) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != duonglichUsage { + t.Errorf("reply = %q, want usage", got) + } + }) + } +} diff --git a/internal/modules/amlich/lunar.go b/internal/modules/amlich/lunar.go new file mode 100644 index 0000000..8e1fcc6 --- /dev/null +++ b/internal/modules/amlich/lunar.go @@ -0,0 +1,264 @@ +package amlich + +import ( + "fmt" + "math" +) + +// This file ports Hồ Ngọc Đức's lunar-calendar algorithm +// (https://www.informatik.uni-leipzig.de/~duc/amlich/), the de-facto reference +// for the Vietnamese calendar. Lunar months start on the day of the +// astronomical new moon at UTC+7; month numbering is anchored on the month +// containing the winter solstice (lunar month 11), and a year with 13 lunations +// repeats the first month without a major solar term as a leap month (tháng +// nhuận). Computing at any other UTC offset shifts month boundaries — this is +// why the Vietnamese and Chinese calendars occasionally disagree. + +// lunarTimeZone is the UTC offset (hours) the Vietnamese calendar is defined +// against. +const lunarTimeZone = 7.0 + +const ( + // newMoonCycle is the mean synodic month length in days. + newMoonCycle = 29.530588853 + // jdNewMoonEpoch is the Julian day of the reference new moon (Jan 1900) + // that lunation indices count from. + jdNewMoonEpoch = 2415021.076998695 +) + +// minLunarYear/maxLunarYear bound both commands. The astronomical formulas +// stay accurate well beyond this window, but the published Vietnamese +// reference tables cover 1800–2199, so claims outside it are unverifiable. +const ( + minLunarYear = 1800 + maxLunarYear = 2199 +) + +// floorInt mirrors the reference implementation's INT() — floor, not +// truncation, which differs for the negative lunation indices of pre-1900 +// dates. +func floorInt(x float64) int { return int(math.Floor(x)) } + +// jdFromDate returns the Julian day number of dd/mm/yy at noon. Handles the +// Julian→Gregorian switch, though all supported years are Gregorian. +func jdFromDate(dd, mm, yy int) int { + a := (14 - mm) / 12 + y := yy + 4800 - a + m := mm + 12*a - 3 + jd := dd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045 + if jd < 2299161 { + jd = dd + (153*m+2)/5 + 365*y + y/4 - 32083 + } + return jd +} + +// jdToDate is the inverse of jdFromDate. +func jdToDate(jd int) (dd, mm, yy int) { + var a, b, c int + if jd > 2299160 { + a = jd + 32044 + b = (4*a + 3) / 146097 + c = a - (b*146097)/4 + } else { + c = jd + 32082 + } + d := (4*c + 3) / 1461 + e := c - (1461*d)/4 + m := (5*e + 2) / 153 + dd = e - (153*m+2)/5 + 1 + mm = m + 3 - 12*(m/10) + yy = b*100 + d - 4800 + m/10 + return dd, mm, yy +} + +// newMoonJd returns the Julian day (with fraction, UTC) of the k-th new moon +// after the reference epoch, using the truncated series from Jean Meeus, +// "Astronomical Algorithms". +func newMoonJd(k int) float64 { + kf := float64(k) + T := kf / 1236.85 // time in Julian centuries from 1900 January 0.5 + T2 := T * T + T3 := T2 * T + dr := math.Pi / 180 + jd1 := 2415020.75933 + 29.53058868*kf + 0.0001178*T2 - 0.000000155*T3 + jd1 += 0.00033 * math.Sin((166.56+132.87*T-0.009173*T2)*dr) + M := 359.2242 + 29.10535608*kf - 0.0000333*T2 - 0.00000347*T3 // sun's mean anomaly + Mpr := 306.0253 + 385.81691806*kf + 0.0107306*T2 + 0.00001236*T3 // moon's mean anomaly + F := 21.2964 + 390.67050646*kf - 0.0016528*T2 - 0.00000239*T3 // moon's argument of latitude + c1 := (0.1734-0.000393*T)*math.Sin(M*dr) + 0.0021*math.Sin(2*M*dr) + c1 -= 0.4068 * math.Sin(Mpr*dr) + c1 += 0.0161 * math.Sin(2*Mpr*dr) + c1 -= 0.0004 * math.Sin(3*Mpr*dr) + c1 += 0.0104 * math.Sin(2*F*dr) + c1 -= 0.0051 * math.Sin((M+Mpr)*dr) + c1 -= 0.0074 * math.Sin((M-Mpr)*dr) + c1 += 0.0004 * math.Sin((2*F+M)*dr) + c1 -= 0.0004 * math.Sin((2*F-M)*dr) + c1 -= 0.0006 * math.Sin((2*F+Mpr)*dr) + c1 += 0.0010 * math.Sin((2*F-Mpr)*dr) + c1 += 0.0005 * math.Sin((2*Mpr+M)*dr) + var deltat float64 + if T < -11 { + deltat = 0.001 + 0.000839*T + 0.0002261*T2 - 0.00000845*T3 - 0.000000081*T*T3 + } else { + deltat = -0.000278 + 0.000265*T + 0.000262*T2 + } + return jd1 + c1 - deltat +} + +// getNewMoonDay returns the calendar day (Julian day number at UTC+7) on which +// the k-th new moon falls. +func getNewMoonDay(k int) int { + return floorInt(newMoonJd(k) + 0.5 + lunarTimeZone/24) +} + +// sunLongitude returns the sun's apparent ecliptic longitude in radians +// [0, 2π) at the instant jdn. +func sunLongitude(jdn float64) float64 { + T := (jdn - 2451545.0) / 36525 // Julian centuries from J2000 + T2 := T * T + dr := math.Pi / 180 + M := 357.52910 + 35999.05030*T - 0.0001559*T2 - 0.00000048*T*T2 + L0 := 280.46645 + 36000.76983*T + 0.0003032*T2 + DL := (1.914600 - 0.004817*T - 0.000014*T2) * math.Sin(dr*M) + DL += (0.019993-0.000101*T)*math.Sin(dr*2*M) + 0.000290*math.Sin(dr*3*M) + L := (L0 + DL) * dr + L -= 2 * math.Pi * math.Floor(L/(2*math.Pi)) + return L +} + +// getSunLongitude returns the major-term index (0..11, one per 30° of solar +// longitude) at local midnight beginning the given calendar day. A lunar month +// containing no major-term transition is the leap month. +func getSunLongitude(dayNumber int) int { + return floorInt(sunLongitude(float64(dayNumber)-0.5-lunarTimeZone/24) / math.Pi * 6) +} + +// getLunarMonth11 returns the start day of lunar month 11 of year yy — the +// month containing the winter solstice (sun longitude 270°, term index 9). +func getLunarMonth11(yy int) int { + off := jdFromDate(31, 12, yy) - 2415021 + k := floorInt(float64(off) / newMoonCycle) + nm := getNewMoonDay(k) + if getSunLongitude(nm) >= 9 { + nm = getNewMoonDay(k - 1) + } + return nm +} + +// getLeapMonthOffset returns how many months after month 11 (whose start day +// is a11) the leap month begins — the first month with no major-term +// transition. Only meaningful in a 13-lunation year. +func getLeapMonthOffset(a11 int) int { + k := floorInt((float64(a11)-jdNewMoonEpoch)/newMoonCycle + 0.5) + i := 1 // start with the month following lunar month 11 + arc := getSunLongitude(getNewMoonDay(k + i)) + for { + last := arc + i++ + arc = getSunLongitude(getNewMoonDay(k + i)) + if arc == last || i >= 14 { + break + } + } + return i - 1 +} + +// solarToLunar converts a Gregorian date to its Vietnamese lunar date. +// leap reports whether the resulting month is the leap month (tháng nhuận). +func solarToLunar(dd, mm, yy int) (day, month, year int, leap bool) { + dayNumber := jdFromDate(dd, mm, yy) + k := floorInt((float64(dayNumber) - jdNewMoonEpoch) / newMoonCycle) + monthStart := getNewMoonDay(k + 1) + if monthStart > dayNumber { + monthStart = getNewMoonDay(k) + } + a11 := getLunarMonth11(yy) + b11 := a11 + if a11 >= monthStart { + year = yy + a11 = getLunarMonth11(yy - 1) + } else { + year = yy + 1 + b11 = getLunarMonth11(yy + 1) + } + day = dayNumber - monthStart + 1 + diff := (monthStart - a11) / 29 + month = diff + 11 + if b11-a11 > 365 { // 13 lunations between successive month-11 starts → leap year + leapMonthDiff := getLeapMonthOffset(a11) + if diff >= leapMonthDiff { + month = diff + 10 + if diff == leapMonthDiff { + leap = true + } + } + } + if month > 12 { + month -= 12 + } + if month >= 11 && diff < 4 { + year-- + } + return day, month, year, leap +} + +// lunarToSolar converts a Vietnamese lunar date to Gregorian. Unlike the +// reference implementation (which silently returns a wrong date), it rejects +// impossible inputs — a leap month the year does not have, or day 30 of a +// 29-day month — with a user-ready Vietnamese message. +func lunarToSolar(day, month, year int, leap bool) (dd, mm, yy int, err error) { + var a11, b11 int + if month < 11 { + a11 = getLunarMonth11(year - 1) + b11 = getLunarMonth11(year) + } else { + a11 = getLunarMonth11(year) + b11 = getLunarMonth11(year + 1) + } + k := floorInt(0.5 + (float64(a11)-jdNewMoonEpoch)/newMoonCycle) + off := month - 11 + if off < 0 { + off += 12 + } + if b11-a11 > 365 { + leapOff := getLeapMonthOffset(a11) + leapMonth := leapOff - 2 + if leapMonth < 0 { + leapMonth += 12 + } + if leap && month != leapMonth { + return 0, 0, 0, fmt.Errorf("năm âm lịch %d không có tháng %d nhuận", year, month) + } + if leap || off >= leapOff { + off++ + } + } else if leap { + return 0, 0, 0, fmt.Errorf("năm âm lịch %d không có tháng nhuận", year) + } + monthStart := getNewMoonDay(k + off) + monthDays := getNewMoonDay(k+off+1) - monthStart + if day > monthDays { + return 0, 0, 0, fmt.Errorf("tháng %d%s năm âm lịch %d chỉ có %d ngày", month, leapLabel(leap), year, monthDays) + } + dd, mm, yy = jdToDate(monthStart + day - 1) + return dd, mm, yy, nil +} + +// canNames and chiNames are the sexagesimal-cycle stems and branches used to +// name lunar years (Giáp Thìn, Ất Tỵ, …). +var canNames = [...]string{"Giáp", "Ất", "Bính", "Đinh", "Mậu", "Kỷ", "Canh", "Tân", "Nhâm", "Quý"} +var chiNames = [...]string{"Tý", "Sửu", "Dần", "Mão", "Thìn", "Tỵ", "Ngọ", "Mùi", "Thân", "Dậu", "Tuất", "Hợi"} + +// canChiYear returns the can-chi name of a lunar year, e.g. 2024 → "Giáp Thìn". +func canChiYear(year int) string { + return canNames[(year+6)%10] + " " + chiNames[(year+8)%12] +} + +// leapLabel renders the " nhuận" suffix appended to leap-month numbers. +func leapLabel(leap bool) string { + if leap { + return " nhuận" + } + return "" +} diff --git a/internal/modules/amlich/lunar_test.go b/internal/modules/amlich/lunar_test.go new file mode 100644 index 0000000..68d23a1 --- /dev/null +++ b/internal/modules/amlich/lunar_test.go @@ -0,0 +1,147 @@ +package amlich + +import "testing" + +// knownDates anchors the algorithm to independently published Vietnamese +// calendar facts: Tết dates, leap-month starts, and two national holidays +// whose lunar dates are widely documented. +var knownDates = []struct { + name string + solarDay, solarMonth, solarYear int + lunarDay, lunarMonth, lunarYear int + leap bool +}{ + {"Tết Ất Tỵ", 29, 1, 2025, 1, 1, 2025, false}, + {"Tết Giáp Thìn", 10, 2, 2024, 1, 1, 2024, false}, + {"Tết Quý Mão", 22, 1, 2023, 1, 1, 2023, false}, + {"Tết Bính Ngọ", 17, 2, 2026, 1, 1, 2026, false}, + {"start of leap month 2, Quý Mão", 22, 3, 2023, 1, 2, 2023, true}, + {"start of leap month 4, Canh Tý", 23, 5, 2020, 1, 4, 2020, true}, + {"Quốc khánh 1945 — 26/7 Ất Dậu", 2, 9, 1945, 26, 7, 1945, false}, + {"30/4/1975 — 20/3 Ất Mão", 30, 4, 1975, 20, 3, 1975, false}, +} + +func TestSolarToLunar_KnownDates(t *testing.T) { + for _, tc := range knownDates { + t.Run(tc.name, func(t *testing.T) { + d, m, y, leap := solarToLunar(tc.solarDay, tc.solarMonth, tc.solarYear) + if d != tc.lunarDay || m != tc.lunarMonth || y != tc.lunarYear || leap != tc.leap { + t.Errorf("solarToLunar(%d/%d/%d) = %d/%d/%d leap=%v, want %d/%d/%d leap=%v", + tc.solarDay, tc.solarMonth, tc.solarYear, + d, m, y, leap, tc.lunarDay, tc.lunarMonth, tc.lunarYear, tc.leap) + } + }) + } +} + +func TestLunarToSolar_KnownDates(t *testing.T) { + for _, tc := range knownDates { + t.Run(tc.name, func(t *testing.T) { + d, m, y, err := lunarToSolar(tc.lunarDay, tc.lunarMonth, tc.lunarYear, tc.leap) + if err != nil { + t.Fatalf("lunarToSolar(%d/%d/%d leap=%v): %v", + tc.lunarDay, tc.lunarMonth, tc.lunarYear, tc.leap, err) + } + if d != tc.solarDay || m != tc.solarMonth || y != tc.solarYear { + t.Errorf("lunarToSolar(%d/%d/%d leap=%v) = %d/%d/%d, want %d/%d/%d", + tc.lunarDay, tc.lunarMonth, tc.lunarYear, tc.leap, + d, m, y, tc.solarDay, tc.solarMonth, tc.solarYear) + } + }) + } +} + +// TestSolarLunarRoundTrip converts every day of 1950–2050 solar→lunar→solar. +// Identity across a century (including ~37 leap months) rules out whole +// classes of boundary bugs without needing external truth per day. +func TestSolarLunarRoundTrip(t *testing.T) { + start := jdFromDate(1, 1, 1950) + end := jdFromDate(31, 12, 2050) + for jd := start; jd <= end; jd++ { + solarDay, solarMonth, solarYear := jdToDate(jd) + lunarDay, lunarMonth, lunarYear, leap := solarToLunar(solarDay, solarMonth, solarYear) + if lunarDay < 1 || lunarDay > 30 || lunarMonth < 1 || lunarMonth > 12 { + t.Fatalf("solarToLunar(%d/%d/%d) out of range: %d/%d/%d", + solarDay, solarMonth, solarYear, lunarDay, lunarMonth, lunarYear) + } + gotDay, gotMonth, gotYear, err := lunarToSolar(lunarDay, lunarMonth, lunarYear, leap) + if err != nil { + t.Fatalf("round trip %d/%d/%d → %d/%d/%d leap=%v: %v", + solarDay, solarMonth, solarYear, lunarDay, lunarMonth, lunarYear, leap, err) + } + if gotDay != solarDay || gotMonth != solarMonth || gotYear != solarYear { + t.Fatalf("round trip %d/%d/%d → %d/%d/%d leap=%v → %d/%d/%d", + solarDay, solarMonth, solarYear, lunarDay, lunarMonth, lunarYear, leap, + gotDay, gotMonth, gotYear) + } + } +} + +func TestLunarToSolar_LeapMonth2025RoundTrip(t *testing.T) { + // Ất Tỵ 2025 has a leap month 6. + solarDay, solarMonth, solarYear, err := lunarToSolar(1, 6, 2025, true) + if err != nil { + t.Fatalf("lunarToSolar(1/6 nhuận/2025): %v", err) + } + d, m, y, leap := solarToLunar(solarDay, solarMonth, solarYear) + if d != 1 || m != 6 || y != 2025 || !leap { + t.Errorf("round trip of 1/6 nhuận/2025 via %d/%d/%d = %d/%d/%d leap=%v", + solarDay, solarMonth, solarYear, d, m, y, leap) + } +} + +func TestLunarToSolar_RejectsImpossibleLeapMonth(t *testing.T) { + // 2023's leap month is 2 — a leap month 3 does not exist. + if _, _, _, err := lunarToSolar(1, 3, 2023, true); err == nil { + t.Error("lunarToSolar(1/3 nhuận/2023) accepted; 2023's leap month is 2") + } + // 2024 has no leap month at all. + if _, _, _, err := lunarToSolar(1, 2, 2024, true); err == nil { + t.Error("lunarToSolar(1/2 nhuận/2024) accepted; 2024 has no leap month") + } +} + +func TestLunarToSolar_RejectsDay30OfShortMonth(t *testing.T) { + // Locate a 29-day month using the module itself: lunar 2024 has no leap + // month, so months 1..12 are consecutive and day-1 starts are adjacent. + // The astronomy is anchored by the known-date tests above. + found := false + for m := 1; m <= 11; m++ { + d1, m1, y1, err := lunarToSolar(1, m, 2024, false) + if err != nil { + t.Fatalf("lunarToSolar(1/%d/2024): %v", m, err) + } + d2, m2, y2, err := lunarToSolar(1, m+1, 2024, false) + if err != nil { + t.Fatalf("lunarToSolar(1/%d/2024): %v", m+1, err) + } + if jdFromDate(d2, m2, y2)-jdFromDate(d1, m1, y1) != 29 { + continue + } + found = true + if _, _, _, err := lunarToSolar(30, m, 2024, false); err == nil { + t.Errorf("lunarToSolar(30/%d/2024) accepted; month has only 29 days", m) + } + if _, _, _, err := lunarToSolar(29, m, 2024, false); err != nil { + t.Errorf("lunarToSolar(29/%d/2024) rejected: %v", m, err) + } + break + } + if !found { + t.Fatal("no 29-day month found in lunar year 2024 — month-length computation is broken") + } +} + +func TestCanChiYear(t *testing.T) { + for year, want := range map[int]string{ + 2024: "Giáp Thìn", + 2025: "Ất Tỵ", + 2026: "Bính Ngọ", + 1945: "Ất Dậu", + 1975: "Ất Mão", + } { + if got := canChiYear(year); got != want { + t.Errorf("canChiYear(%d) = %q, want %q", year, got, want) + } + } +}