diff --git a/pipelines/rust/src/chunked_emitter.rs b/pipelines/rust/src/chunked_emitter.rs index 767d2e5..ef47aae 100644 --- a/pipelines/rust/src/chunked_emitter.rs +++ b/pipelines/rust/src/chunked_emitter.rs @@ -291,7 +291,7 @@ fn write_sheet_module(partition: &SheetPartition<'_>, w: &mut W) -> st writeln!(w)?; // Runtime helpers for Excel functions — import from shared module - writeln!(w, "{}", "import { _div, _aggNum, _index, _match, _vlookup, _hlookup, _large, _small, _rank, _fn, _sumif, _sumifs, _countif, _countifs, _offset, _matchesCriteria, _colNum, _numToCol, computeNPV, computeIRR, computeXIRR, computePMT, computePV, computeFV, computeRATE, computeNPER, computeXNPV, _minifs, _maxifs, _averageif, _averageifs, _filter, _excelSerialFromYMD, _edate, _eomonth } from './_helpers.mjs';")?; + writeln!(w, "{}", "import { _div, _aggNum, _index, _match, _vlookup, _hlookup, _large, _small, _rank, _fn, _sumif, _sumifs, _countif, _countifs, _offset, _matchesCriteria, _colNum, _numToCol, computeNPV, computeIRR, computeXIRR, computePMT, computePV, computeFV, computeRATE, computeNPER, computeXNPV, _minifs, _maxifs, _averageif, _averageifs, _filter, _excelSerialFromYMD, _serialToYMD, _edate, _eomonth } from './_helpers.mjs';")?; writeln!(w)?; // compute(ctx) function diff --git a/pipelines/rust/src/transpiler.rs b/pipelines/rust/src/transpiler.rs index dc5c3e9..47c128a 100644 --- a/pipelines/rust/src/transpiler.rs +++ b/pipelines/rust/src/transpiler.rs @@ -566,9 +566,14 @@ fn transpile_function(name: &str, args: &[Expr], config: &TranspileConfig) -> St // ---------------------------------------------------------------- "TODAY" => "/* TODAY */ 0".to_string(), "NOW" => "/* NOW */ 0".to_string(), - "YEAR" => format!("/* YEAR */ new Date(({} - 25569) * 86400000).getFullYear()", arg(0)), - "MONTH" => format!("/* MONTH */ (new Date(({} - 25569) * 86400000).getMonth() + 1)", arg(0)), - "DAY" => format!("/* DAY */ new Date(({} - 25569) * 86400000).getDate()", arg(0)), + // YEAR/MONTH/DAY must use the UTC/epoch-quirk-aware serial helper. The old + // `new Date((s - 25569) * 86400000).getMonth()` used LOCAL-time getters, so + // any runtime west of UTC read every serial one day early (DAY(Jun-30)=29), + // shifting date-keyed COUNTIFS/SUMIFS windows and DATE(y,MONTH(x),DAY(x)) + // reconstructions by one day. + "YEAR" => format!("/* YEAR */ _serialToYMD({}).y", arg(0)), + "MONTH" => format!("/* MONTH */ _serialToYMD({}).m", arg(0)), + "DAY" => format!("/* DAY */ _serialToYMD({}).d", arg(0)), // DATE/EDATE/EOMONTH return INTEGER Excel day-serials via calendar-exact // helpers. The old `*365.25`/`*30.44` float approximation drifted up to // ~2.88 days/year, breaking exact-equality SUMIFS/MINIFS date-key lookups diff --git a/pipelines/rust/tests/test-date-axis-sumifs.mjs b/pipelines/rust/tests/test-date-axis-sumifs.mjs index 3b2793a..0f08f1f 100644 --- a/pipelines/rust/tests/test-date-axis-sumifs.mjs +++ b/pipelines/rust/tests/test-date-axis-sumifs.mjs @@ -229,6 +229,66 @@ console.log('Testing: EDATE clamp, EOMONTH last-day/leap-year, DATE integer seri cleanup(); } +// --------------------------------------------------------------------------- +// YEAR/MONTH/DAY timezone independence. The old lowering used LOCAL-time +// getters (`new Date((s - 25569) * 86400000).getDate()`), so any runtime west +// of UTC read every serial one day early: DAY(Jun-30-2023) = 29, and the +// DATE(YEAR(x),MONTH(x),DAY(x)) reconstruction idiom (live on A-1 +// Valuation!G7) rebuilt the date one serial low — shifting every date-keyed +// COUNTIFS/SUMIFS window downstream by a day. The engine must give the same +// (Excel-exact) answer in every timezone, so we run it in CHILD processes with +// TZ pinned west (America/Denver) and east (Pacific/Auckland) of UTC. +// --------------------------------------------------------------------------- +console.log('Testing: YEAR/MONTH/DAY are timezone-independent (UTC serial math)'); +{ + const JUN30 = ser(2023, 6, 30); // 45107 + const TZS = { '!ref': 'A1:B5' }; + TZS['A1'] = { t: 'n', v: JUN30 }; + TZS['B1'] = { t: 'n', v: 0, f: 'DAY(A1)' }; + TZS['B2'] = { t: 'n', v: 0, f: 'MONTH(A1)' }; + TZS['B3'] = { t: 'n', v: 0, f: 'YEAR(A1)' }; + // The Valuation!G7 idiom: rebuild a date from its own parts. + TZS['B4'] = { t: 'n', v: 0, f: 'DATE(YEAR(A1),MONTH(A1),DAY(A1))' }; + TZS['A5'] = { t: 'n', v: ser(2024, 3, 1) }; + TZS['B5'] = { t: 'n', v: 0, f: 'DAY(A5)' }; // month boundary, leap year + + // Build once; run the engine in TZ-pinned children. + const wb = { SheetNames: ['TZS'], Sheets: { TZS } }; + const tmp = mkdtempSync(join(tmpdir(), 'date47tz-')); + writeFileSync(join(tmp, 'm.xlsx'), XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })); + execFileSync(PARSER, [join(tmp, 'm.xlsx'), join(tmp, 'out'), '--chunked'], { encoding: 'utf-8', stdio: 'pipe' }); + + const childScript = ` + import e from ${JSON.stringify(pathToFileURL(join(tmp, 'out', 'chunked', 'engine.js')).href)}; + const r = e.run(); + // Hazard probe: what the OLD local-time lowering would compute for DAY in + // THIS child's TZ (proves the negative control actually discriminates). + const oldDay = new Date((${JUN30} - 25569) * 86400000).getDate(); + process.stdout.write(JSON.stringify({ oldDay, + d: r.values['TZS!B1'], m: r.values['TZS!B2'], y: r.values['TZS!B3'], + rebuilt: r.values['TZS!B4'], leapD: r.values['TZS!B5'] })); + `; + const runInTz = (tz) => JSON.parse(execFileSync( + process.execPath, ['--input-type=module', '-e', childScript], + { encoding: 'utf-8', env: { ...process.env, TZ: tz }, stdio: ['ignore', 'pipe', 'pipe'] } + )); + + const west = runInTz('America/Denver'); + // Negative control: in a west-of-UTC runtime the OLD lowering reads Jun 30 as + // 29 — if this ever stops holding, the test is no longer exercising the hazard. + assert(west.oldDay === 29, `negative control: old local-time DAY() reads Jun-30 as 29 in America/Denver (got ${west.oldDay})`); + assert(west.d === 30, `DAY(Jun-30-2023) = 30 in a west-of-UTC runtime (got ${west.d})`); + assert(west.m === 6, `MONTH(Jun-30-2023) = 6 in a west-of-UTC runtime (got ${west.m})`); + assert(west.y === 2023, `YEAR(Jun-30-2023) = 2023 in a west-of-UTC runtime (got ${west.y})`); + assert(west.rebuilt === JUN30, `DATE(YEAR,MONTH,DAY) round-trips the serial exactly (got ${west.rebuilt}, want ${JUN30})`); + assert(west.leapD === 1, `DAY(Mar-1-2024) = 1 in a west-of-UTC runtime (got ${west.leapD})`); + + const east = runInTz('Pacific/Auckland'); + assert(east.d === 30 && east.rebuilt === JUN30, `east-of-UTC runtime agrees (DAY=${east.d}, rebuilt=${east.rebuilt})`); + + rmSync(tmp, { recursive: true, force: true }); +} + console.log(''); console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); process.exit(failed > 0 ? 1 : 0);