fuzzyworld

when-am-i / files / src / utils / calendars.test.ts

calendars.test.ts

12.1 KB · history · blame

1import { describe, it, expect } from 'vitest';
2import { convert, wintersSince } from './calendars';
3import type { AnchorEvent } from '../types';
4
5describe('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 });
9 expect(c.localDateLabel).toBe('March 22, 1700 AD');
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 });
19 expect(c.localDateLabel).toBe('October 5, 1582 AD');
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
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
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
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
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
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
119 it('falls back to descriptive-only for unknown calendars', () => {
120 const c = convert({ year: 1700, month: 4, day: 1 }, 'Some Unknown Calendar');
121 expect(c.convertible).toBe(false);
122 expect(c.system).toBe('Some Unknown Calendar');
123 expect(c.localYearLabel).toBe('1700'); // Gregorian reference
124 expect(c.note).toBeTruthy();
125 expect(c.localDateLabel).toBeUndefined();
126 });
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 });
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
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
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
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 });
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 });
195});
196
197describe('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});