diff --git a/src/__tests__/lc/western/western.spec.ts b/src/__tests__/lc/western/western.spec.ts index f213bcdb..e8bcabd1 100644 --- a/src/__tests__/lc/western/western.spec.ts +++ b/src/__tests__/lc/western/western.spec.ts @@ -82,6 +82,55 @@ describe('gregorian to longcount', () => { }); }); +// Helper function to extract year from ISO 8601 date string +// Handles both positive years (e.g., '2024-01-01' -> '2024') +// and negative years for BCE (e.g., '-0332-03-05' -> '-0332') +const extractYearFromISODate = (isoDate: string): string => { + // Match year at the start: optional minus, followed by digits, up to first dash + const match = isoDate.match(/^(-?\d+)-/); + if (!match) { + throw new Error(`Invalid ISO date format: ${isoDate}`); + } + return match[1]; +}; + +describe('gregorian ISO 8601 parsing', () => { + const gregorianFactory = new GregorianFactory(); + + it('should parse ISO 8601 CE dates', () => { + const g1 = gregorianFactory.parse('2024-01-01'); + expect(g1.toString()).to.eq('1/1/2024 CE'); + + const g2 = gregorianFactory.parse('0062-06-08'); + expect(g2.toString()).to.eq('8/6/62 CE'); + }); + + it('should parse ISO 8601 BCE dates', () => { + const g1 = gregorianFactory.parse('-0332-03-05'); + expect(g1.toString()).to.eq('5/3/333 BCE'); + }); + + it('should support round-trip conversion', () => { + const original = gregorianFactory.parse('2024-01-15'); + const iso = original.toISOString(); + expect(iso).to.eq('2024-01-15'); + const reparsed = gregorianFactory.parse(iso); + expect(reparsed.toString()).to.eq(original.toString()); + }); + + it('should maintain backward compatibility with DD/MM/YYYY format', () => { + const iso = gregorianFactory.parse('2024-12-21'); + const ddmmyyyy = gregorianFactory.parse('21/12/2024 CE'); + expect(iso.toString()).to.eq(ddmmyyyy.toString()); + }); + + it('should format BCE dates correctly with toISOString', () => { + const bceDate = gregorianFactory.parse('5/3/333 BCE'); + const iso = bceDate.toISOString(); + expect(iso).to.eq('-0332-03-05'); + }); +}); + describe('longcount to julian', () => { dates.forEach((dc) => { it(`lc(${dc.lc}) -> j(${dc.julian}: ${dc.jday})`, () => { @@ -110,6 +159,8 @@ describe('longcount to mayadate', () => { }); describe('JSON Dataset Correlation Tests', () => { + // Load data with various correlation constants, including 584285 (commonly called GMT+2 or Astronomical) + // Individual tests use the correlation constant from each specific data entry const jsonGmtData = getGMTCorrelationData(); describe('Direct source correlations validation', () => { @@ -124,13 +175,14 @@ describe('JSON Dataset Correlation Tests', () => { // Validate the Long Count parses correctly expect(lc).to.not.equal(null); - // This is a basic test - you may need to adjust date format comparison - // based on how your library formats dates vs the JSON ISO format + // Compare dates in ISO 8601 format + // Note: Due to known offset calculation issues in the library for certain date ranges, + // we currently only verify the year component exactly. Full date matching will be + // enabled once the offset calculation bugs are fixed. if (correlation.western_calendar === 'gregorian') { - const year = correlation.western_date.split('-')[0]; - const gregorianDate = `${lc.gregorian}`; - // Remove leading zeros for comparison (e.g., 0397 -> 397) - expect(gregorianDate).to.include(year.replace(/^0+/, '')); + const expectedYear = extractYearFromISODate(correlation.western_date); + const actualYear = extractYearFromISODate(lc.gregorian.toISOString()); + expect(actualYear).to.equal(expectedYear, `Year mismatch for ${correlation.maya_long_count}`); } }); }); @@ -150,12 +202,14 @@ describe('JSON Dataset Correlation Tests', () => { expect(`${lc.gregorian}`).to.be.a('string'); expect(lc.julianDay).to.be.a('number'); expect(lc.getPosition()).to.be.a('number'); - - // Extract year for comparison (adjust format as needed) - const expectedYear = correlation.western_date.split('-')[0]; - const gregorianDate = `${lc.gregorian}`; - // Remove leading zeros for comparison - expect(gregorianDate).to.include(expectedYear.replace(/^0+/, '')); + + // Compare dates in ISO 8601 format + // Note: Due to known offset calculation issues in the library for certain date ranges, + // we currently only verify the year component exactly. Full date matching will be + // enabled once the offset calculation bugs are fixed. + const expectedYear = extractYearFromISODate(correlation.western_date); + const actualYear = extractYearFromISODate(lc.gregorian.toISOString()); + expect(actualYear).to.equal(expectedYear, `Year mismatch for ${correlation.maya_long_count}`); }); }); }); diff --git a/src/factory/gregorian.ts b/src/factory/gregorian.ts index a9b7cc76..68be9597 100644 --- a/src/factory/gregorian.ts +++ b/src/factory/gregorian.ts @@ -8,64 +8,100 @@ export default class GregorianFactory { /** * Parse a Gregorian calendar date string into a {@link GregorianCalendarDate}. * - * The input is expected to be in the form `DD/MM/YYYY`, optionally suffixed - * with `" CE"` or `" BCE"` and/or an asterisk (`*`). For BCE dates, the - * year component is converted to a negative year before being passed to - * the moonbeams `calendarToJd` function. + * Supports two input formats: + * 1. `DD/MM/YYYY` format, optionally suffixed with `" CE"` or `" BCE"` and/or an asterisk (`*`) + * 2. ISO 8601 format: `YYYY-MM-DD` (astronomical year numbering for BCE: -0001 = 2 BCE) + * + * For BCE dates in DD/MM/YYYY format, the year component is converted to a negative year + * before being passed to the moonbeams `calendarToJd` function. * * The method calculates the appropriate julian day by: * 1. Converting the Gregorian date to a julian day using moonbeams * 2. Determining the offset needed based on the julian day * 3. Storing the adjusted julian day in the GregorianCalendarDate * - * @param gregorian - Gregorian date string to parse (e.g. `"01/01/0001 CE"`, - * `"31/12/0001 BCE"`, or `"01/01/2000*"`). + * @param gregorian - Gregorian date string to parse. Examples: + * - DD/MM/YYYY format: `"01/01/0001 CE"`, `"31/12/0001 BCE"`, or `"01/01/2000*"` + * - ISO 8601 format: `"2024-01-01"`, `"-0332-03-05"` (333 BCE) * @returns A {@link GregorianCalendarDate} instance representing the given * Gregorian date. * @throws {Error} If the date string is invalid or malformed. */ parse(gregorian: string): GregorianCalendarDate { - // Clean the input string - remove all asterisks and era markers + // Clean the input string - remove all asterisks let cleanedGregorian = gregorian.replace(/\*/g, '').trim(); - // Determine era (BCE or CE) - let isBCE: boolean = false; - let searchString: string = ''; - if (cleanedGregorian.includes('BCE')) { - isBCE = true; - searchString = 'BCE'; - } else if (cleanedGregorian.includes('CE')) { - isBCE = false; - searchString = 'CE'; - } - - // Remove era markers if present - if (searchString) { - cleanedGregorian = cleanedGregorian.replace(` ${searchString}`, '').replace(searchString, '').trim(); - } - - // Validate basic format: expect three slash-separated numeric components (day/month/year) - const rawParts = cleanedGregorian.split('/'); - if (rawParts.length !== 3) { - throw new Error(`Invalid Gregorian date format: "${gregorian}". Expected format: DD/MM/YYYY`); - } + // Detect format: ISO 8601 (YYYY-MM-DD) vs DD/MM/YYYY + // ISO 8601 pattern: optional minus, 4 or more digits for year, dash, month 01-12, dash, day 01-31 + // Examples: 2024-01-01, 0001-12-31, -0332-03-05, 12345-06-15 + // Note: This regex validates basic ranges but doesn't check leap years or month-specific day counts + // (e.g., allows 02-30). Detailed validation happens later via moonbeams library (line 127+) + const iso8601Pattern = /^(-?\d{4,})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + const iso8601Match = cleanedGregorian.match(iso8601Pattern); + + let day: number; + let month: number; + let year: number; + let isBCE: boolean = false; // Default to CE if no era marker present + + if (iso8601Match) { + // Parse ISO 8601 format: YYYY-MM-DD + const isoYear = parseInt(iso8601Match[1], 10); + month = parseInt(iso8601Match[2], 10); + day = parseInt(iso8601Match[3], 10); + + // ISO 8601 uses astronomical year numbering: year 0 = 1 BCE, -1 = 2 BCE, etc. + if (isoYear < 0) { + isBCE = true; + // Convert from astronomical to historical: -332 → 333 BCE + year = Math.abs(isoYear - 1); + } else if (isoYear === 0) { + isBCE = true; + year = 1; // Year 0 = 1 BCE + } else { + isBCE = false; + year = isoYear; + } + } else { + // Parse DD/MM/YYYY format + // Determine era (BCE or CE, defaults to CE if not specified) + let searchString: string = ''; + if (cleanedGregorian.includes('BCE')) { + isBCE = true; + searchString = 'BCE'; + } else if (cleanedGregorian.includes('CE')) { + searchString = 'CE'; + // isBCE remains false (already initialized) + } - const dateParts: number[] = rawParts.map((part, index) => { - const trimmed = part.trim(); - if (trimmed.length === 0) { - throw new Error(`Invalid Gregorian date component in "${gregorian}": empty component at position ${index + 1}`); + // Remove era markers if present + if (searchString) { + cleanedGregorian = cleanedGregorian.replace(` ${searchString}`, '').replace(searchString, '').trim(); } - const value = Number(trimmed); - if (!Number.isFinite(value) || isNaN(value)) { - throw new Error(`Non-numeric Gregorian date component "${trimmed}" in "${gregorian}"`); + + // Validate basic format: expect three slash-separated numeric components (day/month/year) + const rawParts = cleanedGregorian.split('/'); + if (rawParts.length !== 3) { + throw new Error(`Invalid Gregorian date format: "${gregorian}". Expected format: DD/MM/YYYY (slash-separated day/month/year) or YYYY-MM-DD (ISO 8601)`); } - return value; - }); - // dateParts[0] = day, dateParts[1] = month, dateParts[2] = year - const day = dateParts[0]; - const month = dateParts[1]; - const year = dateParts[2]; + const dateParts: number[] = rawParts.map((part, index) => { + const trimmed = part.trim(); + if (trimmed.length === 0) { + throw new Error(`Invalid Gregorian date component in "${gregorian}": empty component at position ${index + 1}`); + } + const value = Number(trimmed); + if (!Number.isFinite(value) || isNaN(value)) { + throw new Error(`Non-numeric Gregorian date component "${trimmed}" in "${gregorian}"`); + } + return value; + }); + + // dateParts[0] = day, dateParts[1] = month, dateParts[2] = year + day = dateParts[0]; + month = dateParts[1]; + year = dateParts[2]; + } // Validate date component ranges if (month < 1 || month > 12) { diff --git a/src/lc/western/western.ts b/src/lc/western/western.ts index e9040a96..7db29616 100644 --- a/src/lc/western/western.ts +++ b/src/lc/western/western.ts @@ -88,4 +88,25 @@ export default abstract class WesternCalendar { toString() { return `${this.day}/${this.month}/${this.year} ${this.era}`; } + + /** + * Represent this date in ISO 8601 format (YYYY-MM-DD) using astronomical year numbering. + * For BCE dates, uses astronomical year numbering where 1 BCE = year 0, 2 BCE = year -1, etc. + * @return {string} + */ + toISOString(): string { + // Use this.date.year directly as it's already in astronomical year numbering + // (negative for BCE dates: -1 = 2 BCE, 0 = 1 BCE, positive for CE dates) + const isoYear = this.date.year; + + // Format with zero-padding: YYYY-MM-DD + // Handle negative years separately to avoid padding issues with the minus sign + const yearStr = isoYear < 0 + ? '-' + Math.abs(isoYear).toString().padStart(4, '0') + : isoYear.toString().padStart(4, '0'); + const monthStr = this.month.toString().padStart(2, '0'); + const dayStr = this.day.toString().padStart(2, '0'); + + return `${yearStr}-${monthStr}-${dayStr}`; + } }