Skip to content

Commit 8249519

Browse files
committed
Thorough duration totalling tests
Testing Temporal.Duration.prototype.total() with a variety of "interesting" durations, with relativeTo being various PlainDates, various ZonedDateTimes, or nothing. Tests the invariant that total() and round() should agree. This invariant actually does not hold in a surprising number of situations, but as far as I can tell that is correct per the specification and is due to floating-point precision loss which we have correctly accounted for.
1 parent 87fca4c commit 8249519

File tree

4 files changed

+685178
-0
lines changed

4 files changed

+685178
-0
lines changed

polyfill/test/thorough/all.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ for test in \
2121
datetimedifference \
2222
datetimerounding \
2323
durationaddition \
24+
durationtotal \
2425
gregorian \
2526
instantaddition \
2627
instantdifference \
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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

Comments
 (0)