/** Replays selected public calendar outputs, not the private app's interpretation rules. */ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync, realpathSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; export const SOURCE_REVISION = '694adfe64b5f17602c3f4e7359ae97d7232a6426'; export const DATASET_SHA256 = 'bc4d5afcc79dc0b2d5405199cddfae8bd4a203d73f4adc4e5f5a1b067485460d'; const formatTime = value => ( `${value.year}-${String(value.month).padStart(2, '0')}-${String(value.day).padStart(2, '0')} ` + `${String(value.hour).padStart(2, '0')}:${String(value.minute).padStart(2, '0')}:${String(value.second).padStart(2, '0')}` ); /** Project only the fields promised by the frozen public v1 dataset. */ export function projectResult(chart) { const solar = chart.solarTimeInfo; return { pillars: Object.fromEntries(['year', 'month', 'day', 'hour'].map(key => [key, chart.pillars[key]?.ganZhi ?? null])), dayMaster: chart.dayMaster.char, calendar: { inputType: chart.calendar.inputType, civilSolar: formatTime(chart.calendar.civilSolar), calculationSolar: formatTime(chart.calendar.calculationSolar), lunar: formatTime(chart.calendar.lunar), lunarLeapMonth: chart.calendar.lunar.isLeapMonth, }, metadata: { timezoneBasis: chart.metadata.timezoneBasis, dstOffset: chart.metadata.dstOffset, trueSolarTimeApplied: chart.metadata.trueSolarTimeApplied, dayBoundaryMode: chart.metadata.dayBoundaryMode, }, solarTime: solar ? { trueSolarTime: solar.trueSolarTime, trueSolarDateTime: solar.trueSolarDateTime, standardMeridian: solar.standardMeridian, longitudeCorrectionMinutes: Number(solar.longitudeCorrectionMinutes.toFixed(6)), equationOfTimeMinutes: Number(solar.equationOfTimeMinutes.toFixed(6)), algorithm: solar.algorithm, } : null, daYun: { direction: chart.daYun.isForward ? 'forward' : 'backward', startDate: chart.daYun.startDate, startOffset: chart.daYun.startOffset, firstCycle: chart.daYun.cycles[0].ganZhi, }, }; } /** Require every selected public field to match the frozen dataset exactly. */ export function replayCases(calculate, dataset) { assert.equal(dataset.cases.length, 8); const results = dataset.cases.map(testCase => { const actual = projectResult(calculate(testCase.input)); assert.deepEqual(actual, testCase.expected, `${testCase.id}: public-source difference`); return { id: testCase.id, status: 'full-match', differences: [] }; }); assert.equal(results.filter(result => result.status === 'full-match').length, 8); return { sourceRevision: SOURCE_REVISION, fullMatches: 8, casesWithKnownReceiptDifference: 0, results }; } const invokedPath = process.argv[1] ? realpathSync(path.resolve(process.argv[1])) : null; const modulePath = realpathSync(fileURLToPath(import.meta.url)); if (invokedPath && modulePath === invokedPath) { const [sourceDirectory, datasetFile] = process.argv.slice(2); assert.ok(sourceDirectory && datasetFile, 'Usage: node --import tsx replay.mjs '); const sourceRoot = path.resolve(sourceDirectory); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: sourceRoot, encoding: 'utf8' }).trim(); assert.equal(revision, SOURCE_REVISION, 'Check out the documented public source revision'); const trackedChanges = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: sourceRoot, encoding: 'utf8' }); assert.equal(trackedChanges.trim(), '', 'Replay requires unmodified tracked source files'); const rawDataset = readFileSync(path.resolve(datasetFile)); assert.equal(createHash('sha256').update(rawDataset).digest('hex'), DATASET_SHA256, 'Dataset hash mismatch'); const { calculateBaziChart } = await import(pathToFileURL(path.join(sourceRoot, 'src/index.ts')).href); console.log(JSON.stringify(replayCases(calculateBaziChart, JSON.parse(rawDataset)), null, 2)); }