|
| 1 | +import { |
| 2 | + assertEqual, |
| 3 | + assertOffByLessThanOne, |
| 4 | + durationUnits, |
| 5 | + getProgressBar, |
| 6 | + isCalendarUnit, |
| 7 | + makeDurationCases, |
| 8 | + makeRelativeToCases, |
| 9 | + time, |
| 10 | + withSnapshotsFromFile |
| 11 | +} from './support.mjs'; |
| 12 | + |
| 13 | +const positiveCases = makeDurationCases(); |
| 14 | +const negativeCases = positiveCases.map(([duration, str]) => [duration.negated(), '-' + str]); |
| 15 | +const interestingCases = positiveCases.concat(negativeCases); |
| 16 | +const interestingRelativeTo = makeRelativeToCases(); |
| 17 | +const total = interestingCases.length * durationUnits.length; |
| 18 | + |
| 19 | +// Source - https://stackoverflow.com/a/72267395 |
| 20 | +// Posted by Martin Braun, modified by community. See post 'Timeline' for change history |
| 21 | +// Retrieved 2025-12-08, License - CC BY-SA 4.0 |
| 22 | +function roundHalfEven(x) { |
| 23 | + const n = x >= 0 ? 1 : -1; |
| 24 | + const r = n * Math.round(n * x); |
| 25 | + return Math.abs(x) % 1 === 0.5 && r % 2 !== 0 ? r - n : r; |
| 26 | +} |
| 27 | + |
| 28 | +// If the absolute value of the result of total() is ≥ this number, we cannot |
| 29 | +// test the invariant that round() and total() must agree; round() will throw |
| 30 | +// due to not being able to construct the upper bound for rounding, or when |
| 31 | +// converting the internal duration back to JS numbers with ℝ(𝔽(nanoseconds)). |
| 32 | +// Or in the case of sub-second units, total() may suffer from floating-point |
| 33 | +// precision loss. |
| 34 | +const maxTotals = { |
| 35 | + hours: Math.trunc(Number.MAX_SAFE_INTEGER / 3600) + 0.5, |
| 36 | + minutes: Math.trunc(Number.MAX_SAFE_INTEGER / 60) + 0.5, |
| 37 | + seconds: Number.MAX_SAFE_INTEGER, |
| 38 | + milliseconds: Number.MAX_SAFE_INTEGER, |
| 39 | + microseconds: Number.MAX_SAFE_INTEGER, |
| 40 | + nanoseconds: Number.MAX_SAFE_INTEGER |
| 41 | +}; |
| 42 | + |
| 43 | +// round() may correctly round a result that is not exactly between two |
| 44 | +// increments, while total() may give a result that is 0.5 due to precision |
| 45 | +// loss, and can't be correctly rounded after the fact. This happens with |
| 46 | +// milliseconds and microseconds. |
| 47 | +function doubleRoundingMayBeWrong(absResult, unit) { |
| 48 | + return ( |
| 49 | + (unit === 'milliseconds' || unit === 'microseconds') && |
| 50 | + absResult >= Number.MAX_SAFE_INTEGER / 100 && |
| 51 | + absResult % 1 === 0.5 |
| 52 | + ); |
| 53 | +} |
| 54 | + |
| 55 | +await time(async (start) => { |
| 56 | + const progress = getProgressBar(start, total); |
| 57 | + |
| 58 | + await withSnapshotsFromFile('./durationtotal.snapshot.json', (matchSnapshot, matchSnapshotOrOutOfRange) => { |
| 59 | + for (const [duration, str] of interestingCases) { |
| 60 | + const isCalendarDuration = duration.years !== 0 || duration.months !== 0 || duration.weeks !== 0; |
| 61 | + |
| 62 | + for (const unit of durationUnits) { |
| 63 | + const testName = `${str} ${unit}`; |
| 64 | + progress.tick(1, { test: testName.slice(0, 45) }); |
| 65 | + |
| 66 | + for (const [relativeTo, relativeStr] of interestingRelativeTo) { |
| 67 | + const result = matchSnapshotOrOutOfRange( |
| 68 | + () => duration.total({ unit, relativeTo }), |
| 69 | + `${testName} ${relativeStr}` |
| 70 | + ); |
| 71 | + |
| 72 | + if (!result) continue; |
| 73 | + |
| 74 | + const absResult = Math.abs(result); |
| 75 | + |
| 76 | + // See note above maxTotals |
| 77 | + if (maxTotals[unit] && absResult >= maxTotals[unit]) { |
| 78 | + continue; |
| 79 | + } |
| 80 | + |
| 81 | + // We must use halfEven rounding here, because 𝔽(total) uses that |
| 82 | + // rounding mode, which is significant for cases where the nearest |
| 83 | + // integer is safe, but the fraction is not. We then have to re-round |
| 84 | + // total with half-even rounding to match the result from round(). In |
| 85 | + // rare cases it still may not be equal; see note below. |
| 86 | + const rounded = duration.round({ |
| 87 | + largestUnit: unit, |
| 88 | + smallestUnit: unit, |
| 89 | + roundingMode: 'halfEven', |
| 90 | + relativeTo |
| 91 | + })[unit]; |
| 92 | + |
| 93 | + if (doubleRoundingMayBeWrong(absResult, unit)) { |
| 94 | + assertOffByLessThanOne(result, rounded, 'relativeTo total() should agree with round()'); |
| 95 | + } else { |
| 96 | + assertEqual(roundHalfEven(result), rounded, 'relativeTo total() should agree with round()'); |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + // Skip if testing without relativeTo would be invalid |
| 101 | + if (isCalendarUnit(unit) || isCalendarDuration) continue; |
| 102 | + const result = duration.total(unit); |
| 103 | + matchSnapshot(result.toString(), testName); |
| 104 | + |
| 105 | + // See above notes |
| 106 | + const absResult = Math.abs(result); |
| 107 | + if (maxTotals[unit] && absResult >= maxTotals[unit]) { |
| 108 | + continue; |
| 109 | + } |
| 110 | + |
| 111 | + const rounded = duration.round({ largestUnit: unit, smallestUnit: unit, roundingMode: 'halfEven' })[unit]; |
| 112 | + if (doubleRoundingMayBeWrong(absResult, unit)) { |
| 113 | + assertOffByLessThanOne(result, rounded, 'total() should agree with round()'); |
| 114 | + } else { |
| 115 | + assertEqual(roundHalfEven(result), rounded, 'total() should agree with round()'); |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + }); |
| 120 | + |
| 121 | + return total; |
| 122 | +}); |
0 commit comments