mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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.
This commit is contained in:
1 parent
f727bc6b5c
commit
7312dcf7ea
8 files changed
+813
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -58,6 +58,8 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
expectedParameters := map[string]string{
|
||||
"amlich": "[date]",
|
||||
"duonglich": "<date> [nhuan]",
|
||||
"coin_price": "<coin>",
|
||||
"coin_topup": "<usd_amount>",
|
||||
"coin_buy": "<coin> <usd_to_spend>",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 <dd[/mm[/yyyy]]> [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: "<date> [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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user