when-am-i / files / src / utils / calendars.test.ts
calendars.test.ts
a71096e6 2026-06-18 Pavel Safronov | 1 | import { describe, it, expect } from 'vitest'; |
5c66fc6a 2026-06-21 Pavel Safronov | 2 | import { convert, wintersSince } from './calendars'; |
| 3 | import type { AnchorEvent } from '../types'; | |
a71096e6 2026-06-18 Pavel Safronov | 4 | |
| 5 | describe('convert', () => { | |
| 6 | it('Gregorian is the identity on the input year', () => { | |
| 7 | const c = convert({ year: 1700, month: 3, day: 22 }, 'Gregorian'); | |
| 8 | expect(c).toMatchObject({ system: 'Gregorian', localYearLabel: '1700', convertible: true }); | |
8961f70b 2026-06-20 Pavel Safronov | 9 | expect(c.localDateLabel).toBe('March 22, 1700 AD'); |
a71096e6 2026-06-18 Pavel Safronov | 10 | }); |
| 11 | ||
| 12 | it('is case/space-insensitive on the calendar name', () => { | |
| 13 | expect(convert({ year: 1700, month: 1, day: 1 }, ' jUlIaN ').system).toBe('Julian'); | |
| 14 | }); | |
| 15 | ||
| 16 | it('Julian converts 1582-10-15 Gregorian to 1582-10-05', () => { | |
| 17 | const c = convert({ year: 1582, month: 10, day: 15 }, 'Julian'); | |
| 18 | expect(c).toMatchObject({ system: 'Julian', localYearLabel: '1582', convertible: true }); | |
8961f70b 2026-06-20 Pavel Safronov | 19 | expect(c.localDateLabel).toBe('October 5, 1582 AD'); |
a71096e6 2026-06-18 Pavel Safronov | 20 | }); |
| 21 | ||
| 22 | it('labels BCE years using astronomical numbering', () => { | |
| 23 | // Astronomical year 0 == 1 BCE. | |
| 24 | expect(convert({ year: 0, month: 1, day: 1 }, 'Gregorian').localYearLabel).toBe('1 BC'); | |
| 25 | }); | |
| 26 | ||
d7aad2c5 2026-06-18 Pavel Safronov | 27 | // Library-backed (Temporal) calendars, anchored to 2000-01-01 Gregorian. |
| 28 | it('converts to the Islamic (Umm al-Qura) year', () => { | |
| 29 | const c = convert({ year: 2000, month: 1, day: 1 }, 'Islamic'); | |
| 30 | expect(c.convertible).toBe(true); | |
| 31 | expect(c.system).toBe('Islamic (Umm al-Qura)'); | |
| 32 | expect(c.localYearLabel).toBe('AH 1420'); | |
| 33 | }); | |
| 34 | ||
| 35 | it('converts to the Hebrew year', () => { | |
| 36 | const c = convert({ year: 2000, month: 1, day: 1 }, 'Hebrew'); | |
| 37 | expect(c).toMatchObject({ convertible: true, localYearLabel: 'AM 5760' }); | |
| 38 | }); | |
| 39 | ||
| 40 | it('converts to the Persian (Solar Hijri) year', () => { | |
| 41 | const c = convert({ year: 2000, month: 1, day: 1 }, 'Persian'); | |
| 42 | expect(c).toMatchObject({ convertible: true, localYearLabel: 'SH 1378' }); | |
| 43 | }); | |
| 44 | ||
| 45 | it('converts to the Indian (Saka) and Buddhist years', () => { | |
| 46 | expect(convert({ year: 2000, month: 1, day: 1 }, 'Saka').localYearLabel).toBe('Saka 1921'); | |
| 47 | expect(convert({ year: 2000, month: 1, day: 1 }, 'Buddhist').localYearLabel).toBe('BE 2543'); | |
| 48 | }); | |
| 49 | ||
c7d226e1 2026-06-20 Pavel Safronov | 50 | it('spells out the month name in the date label', () => { |
| 51 | // 1700-06-20 Gregorian falls in Muharram (the first Islamic month). | |
| 52 | expect(convert({ year: 1700, month: 6, day: 20 }, 'Islamic').localDateLabel).toMatch(/Muharram/); | |
| 53 | // 2025-03-25 Gregorian falls in Farvardin (the first Persian month). | |
| 54 | expect(convert({ year: 2025, month: 3, day: 25 }, 'Persian').localDateLabel).toMatch(/Farvardin/); | |
| 55 | }); | |
| 56 | ||
4c1ec1d7 2026-06-21 Pavel Safronov | 57 | it('converts to the Japanese nengō era (Meiji onward)', () => { |
| 58 | expect(convert({ year: 2024, month: 6, day: 1 }, 'Japanese').localYearLabel).toBe('Reiwa 6'); | |
| 59 | expect(convert({ year: 1873, month: 1, day: 15 }, 'Japanese').localYearLabel).toBe('Meiji 6'); | |
| 60 | // Pre-Meiji has no modern nengō numbering → descriptive-only fallback. | |
| 61 | const old = convert({ year: 1700, month: 1, day: 1 }, 'Japanese'); | |
| 62 | expect(old.convertible).toBe(false); | |
| 63 | expect(old.localYearLabel).toBe('1700'); // Gregorian reference | |
| 64 | }); | |
| 65 | ||
| 66 | it('converts to the Ethiopian calendar (hand-rolled)', () => { | |
| 67 | // 2000-01-01 Gregorian falls in Ethiopian year 1992 (new year was 1999-09-11/12). | |
| 68 | const c = convert({ year: 2000, month: 1, day: 1 }, 'Ethiopian'); | |
| 69 | expect(c).toMatchObject({ system: 'Ethiopian', convertible: true }); | |
| 70 | expect(c.localYearLabel).toBe('1992 E.C.'); | |
| 71 | expect(c.localDateLabel).toBe('Tahsas 22, 1992 E.C.'); | |
| 72 | // A date just after Ethiopian New Year ticks the year over. | |
| 73 | expect(convert({ year: 2024, month: 9, day: 20 }, 'Ethiopian').localYearLabel).toBe('2017 E.C.'); | |
| 74 | }); | |
| 75 | ||
| 76 | it('names the Chinese sexagenary year (descriptive-only)', () => { | |
| 77 | // 1984 began a new 60-year cycle: Jiǎ-zǐ, the (Wood) Rat. | |
| 78 | const c = convert({ year: 1984, month: 6, day: 1 }, 'Chinese'); | |
| 79 | expect(c.convertible).toBe(false); // named cycle, not a numbered year | |
| 80 | expect(c.system).toBe('Chinese sexagenary cycle'); | |
| 81 | expect(c.localDateLabel).toMatch(/Jiǎ-Zǐ/); | |
| 82 | expect(c.localDateLabel).toMatch(/Wood Rat/); | |
| 83 | expect(c.localYearLabel).toBe('1984'); // Gregorian reference | |
| 84 | expect(convert({ year: 2024, month: 6, day: 1 }, 'Chinese').localDateLabel).toMatch(/Wood Dragon/); | |
| 85 | }); | |
| 86 | ||
64fc68ee 2026-06-21 Pavel Safronov | 87 | it('converts to the Maya Long Count (hand-rolled, GMT correlation)', () => { |
| 88 | // The well-documented completion of the 13th baktun. | |
| 89 | const c = convert({ year: 2012, month: 12, day: 21 }, 'Maya Long Count'); | |
| 90 | expect(c).toMatchObject({ system: 'Maya Long Count', convertible: true }); | |
| 91 | expect(c.localYearLabel).toBe('13.0.0.0.0'); | |
| 92 | // The epoch date itself is 0.0.0.0.0. | |
| 93 | expect(convert({ year: -3113, month: 8, day: 11 }, 'Maya Long Count').localYearLabel).toBe('0.0.0.0.0'); | |
| 94 | }); | |
| 95 | ||
| 96 | it('names the Tzolk\'in/Haab\' Calendar Round as descriptive-only', () => { | |
| 97 | const c = convert({ year: 1500, month: 1, day: 1 }, "Tzolk'in and Haab' Calendar Round"); | |
| 98 | expect(c.convertible).toBe(false); | |
| 99 | expect(c.localYearLabel).toBe('1500'); // Gregorian reference | |
| 100 | }); | |
| 101 | ||
c571572f 2026-06-21 Pavel Safronov | 102 | it('converts to the French Republican year (hand-rolled lookup table)', () => { |
| 103 | // Year I began 1792-09-22; the Thermidorian Reaction was 9 Thermidor An II = 1794-07-27. | |
| 104 | expect(convert({ year: 1792, month: 9, day: 22 }, 'French Republican calendar').localYearLabel).toBe('An I'); | |
| 105 | expect(convert({ year: 1794, month: 7, day: 27 }, 'French Republican calendar')).toMatchObject({ | |
| 106 | convertible: true, | |
| 107 | localYearLabel: 'An II', | |
| 108 | }); | |
| 109 | // The brief 1871 Paris Commune revival. | |
| 110 | expect(convert({ year: 1871, month: 5, day: 10 }, 'French Republican calendar').localYearLabel).toBe('An LXXIX'); | |
| 111 | }); | |
| 112 | ||
| 113 | it('French Republican calendar falls back to descriptive-only outside its periods of use', () => { | |
| 114 | const c = convert({ year: 1850, month: 1, day: 1 }, 'French Republican calendar'); | |
| 115 | expect(c.convertible).toBe(false); | |
| 116 | expect(c.localYearLabel).toBe('1850'); // Gregorian reference | |
| 117 | }); | |
| 118 | ||
a71096e6 2026-06-18 Pavel Safronov | 119 | it('falls back to descriptive-only for unknown calendars', () => { |
8961f70b 2026-06-20 Pavel Safronov | 120 | const c = convert({ year: 1700, month: 4, day: 1 }, 'Some Unknown Calendar'); |
a71096e6 2026-06-18 Pavel Safronov | 121 | expect(c.convertible).toBe(false); |
8961f70b 2026-06-20 Pavel Safronov | 122 | expect(c.system).toBe('Some Unknown Calendar'); |
a71096e6 2026-06-18 Pavel Safronov | 123 | expect(c.localYearLabel).toBe('1700'); // Gregorian reference |
| 124 | expect(c.note).toBeTruthy(); | |
| 125 | expect(c.localDateLabel).toBeUndefined(); | |
| 126 | }); | |
8961f70b 2026-06-20 Pavel Safronov | 127 | |
| 128 | it('Algonquian lunar calendar is descriptive-only but reports the moon', () => { | |
| 129 | const c = convert({ year: 1700, month: 4, day: 1 }, 'Algonquian lunar calendar'); | |
| 130 | expect(c.convertible).toBe(false); // no continuous numeric year | |
| 131 | expect(c.system).toBe('Algonquian lunar calendar'); | |
| 132 | expect(c.localYearLabel).toBe('1700'); // Gregorian reference | |
| 133 | expect(c.localDateLabel).toBeTruthy(); // computed moon phase | |
| 134 | }); | |
| 135 | ||
| 136 | it('computes the moon phase from astronomy', () => { | |
| 137 | // 2000-01-06 was a new moon; ~2000-01-21 was the following full moon. | |
| 138 | const lunar = 'Algonquian lunar calendar'; | |
| 139 | expect(convert({ year: 2000, month: 1, day: 6 }, lunar).localDateLabel).toMatch(/new moon/i); | |
| 140 | expect(convert({ year: 2000, month: 1, day: 21 }, lunar).localDateLabel).toMatch(/full moon/i); | |
| 141 | }); | |
16bbd500 2026-06-21 Pavel Safronov | 142 | |
| 143 | it('converts to the Dangi (Dangun era) year (hand-rolled offset)', () => { | |
| 144 | // 1948 was the year Dangi became South Korea's official year count. | |
| 145 | expect(convert({ year: 1948, month: 9, day: 25 }, 'Dangi').localYearLabel).toBe('Dangi 4281'); | |
| 146 | expect(convert({ year: 2024, month: 1, day: 1 }, 'Dangun era').localYearLabel).toBe('Dangi 4357'); | |
| 147 | }); | |
| 148 | ||
| 149 | it('converts to the Juche year (hand-rolled offset)', () => { | |
| 150 | // Juche year 1 = 1912 (Kim Il-sung's birth year). | |
| 151 | expect(convert({ year: 1997, month: 9, day: 9 }, 'Juche').localYearLabel).toBe('Juche 86'); | |
| 152 | expect(convert({ year: 1912, month: 1, day: 1 }, 'Juche').localYearLabel).toBe('Juche 1'); | |
| 153 | }); | |
| 154 | ||
3900f816 2026-06-23 Pavel Safronov | 155 | it('converts to the ab urbe condita (AUC) year (hand-rolled offset)', () => { |
| 156 | // Rome founded 753 BC, so AD 1 = AUC 754 and 1 BC (astronomical year 0) = AUC 753. | |
| 157 | expect(convert({ year: 1, month: 1, day: 1 }, 'ab urbe condita').localYearLabel).toBe('AUC 754'); | |
| 158 | expect(convert({ year: 0, month: 1, day: 1 }, 'AUC').localYearLabel).toBe('AUC 753'); | |
| 159 | expect(convert({ year: 1, month: 1, day: 1 }, 'ab urbe condita').convertible).toBe(true); | |
| 160 | }); | |
| 161 | ||
| 162 | it('converts to the Seleucid era (Anno Graecorum) year (hand-rolled offset)', () => { | |
| 163 | // Epoch 312/311 BC, so AD 1 ≈ SE 312 (conventional +311 mapping). | |
| 164 | expect(convert({ year: 1, month: 1, day: 1 }, 'Seleucid era').localYearLabel).toBe('SE 312'); | |
| 165 | expect(convert({ year: 1, month: 1, day: 1 }, 'Anno Graecorum').system).toBe('Seleucid era (Anno Graecorum)'); | |
| 166 | expect(convert({ year: 1, month: 1, day: 1 }, 'Seleucid era').convertible).toBe(true); | |
| 167 | }); | |
| 168 | ||
9c2d2d06 2026-06-21 Pavel Safronov | 169 | it('names the Aztec/Mexica year-bearer (descriptive-only)', () => { |
| 170 | // 1519 CE is documented as "1 Ācatl" (Cortés's arrival). | |
| 171 | const c = convert({ year: 1519, month: 6, day: 1 }, 'Aztec/Mexica year-bearer'); | |
| 172 | expect(c.convertible).toBe(false); | |
| 173 | expect(c.system).toBe('Aztec/Mexica year-bearer'); | |
| 174 | expect(c.localDateLabel).toBe('1 Ācatl'); | |
| 175 | expect(c.localYearLabel).toBe('1519'); // Gregorian reference | |
| 176 | // 1507 CE is documented as "2 Ācatl" (the last pre-conquest New Fire Ceremony). | |
| 177 | expect(convert({ year: 1507, month: 6, day: 1 }, 'Aztec year-bearer').localDateLabel).toBe('2 Ācatl'); | |
| 178 | }); | |
| 179 | ||
16bbd500 2026-06-21 Pavel Safronov | 180 | it('Korean lunisolar calendar is descriptive-only but reports the moon', () => { |
| 181 | const c = convert({ year: 1800, month: 4, day: 1 }, 'Korean lunisolar calendar'); | |
| 182 | expect(c.convertible).toBe(false); | |
| 183 | expect(c.system).toBe('Korean lunisolar calendar'); | |
| 184 | expect(c.localYearLabel).toBe('1800'); // Gregorian reference | |
| 185 | expect(c.localDateLabel).toBeTruthy(); // computed moon phase | |
| 186 | }); | |
f24ee458 2026-06-21 Pavel Safronov | 187 | |
| 188 | it('Vietnamese lunisolar calendar is descriptive-only but reports the moon', () => { | |
| 189 | const c = convert({ year: 1929, month: 7, day: 1 }, 'Vietnamese lunisolar calendar'); | |
| 190 | expect(c.convertible).toBe(false); | |
| 191 | expect(c.system).toBe('Vietnamese lunisolar calendar'); | |
| 192 | expect(c.localYearLabel).toBe('1929'); // Gregorian reference | |
| 193 | expect(c.localDateLabel).toBeTruthy(); // computed moon phase | |
| 194 | }); | |
a71096e6 2026-06-18 Pavel Safronov | 195 | }); |
5c66fc6a 2026-06-21 Pavel Safronov | 196 | |
| 197 | describe('wintersSince', () => { | |
| 198 | const events: AnchorEvent[] = [ | |
| 199 | { name: 'the great dying', date: '1616-01-01', sources: ['https://example.org/a'] }, | |
| 200 | { name: 'an earlier event', date: '1500-01-01', sources: ['https://example.org/b'] }, | |
| 201 | ]; | |
| 202 | ||
| 203 | it('returns undefined with no anchor events', () => { | |
| 204 | expect(wintersSince({ year: 1700, month: 1, day: 1 })).toBeUndefined(); | |
| 205 | expect(wintersSince({ year: 1700, month: 1, day: 1 }, [])).toBeUndefined(); | |
| 206 | }); | |
| 207 | ||
| 208 | it('counts whole years since the most recent anchor at or before the date', () => { | |
| 209 | const r = wintersSince({ year: 1700, month: 1, day: 1 }, events); | |
| 210 | expect(r?.label).toBe('about 84 winters since the great dying'); | |
| 211 | expect(r?.sources).toEqual(['https://example.org/a']); | |
| 212 | }); | |
| 213 | ||
| 214 | it('ignores anchor events that are still in the future', () => { | |
| 215 | // 1550 is after the 1500 event but before the 1616 one → counts from 1500. | |
| 216 | expect(wintersSince({ year: 1550, month: 1, day: 1 }, events)?.label).toBe('about 50 winters since an earlier event'); | |
| 217 | // Before every anchor → no reckoning. | |
| 218 | expect(wintersSince({ year: 1400, month: 1, day: 1 }, events)).toBeUndefined(); | |
| 219 | }); | |
| 220 | ||
| 221 | it('reads as singular / sub-year near the anchor', () => { | |
| 222 | expect(wintersSince({ year: 1616, month: 6, day: 1 }, events)?.label).toBe('less than a year since the great dying'); | |
| 223 | expect(wintersSince({ year: 1617, month: 6, day: 1 }, events)?.label).toBe('about 1 winter since the great dying'); | |
| 224 | }); | |
| 225 | }); |