Skip to content

Commit dc20476

Browse files
committed
test: clarify mixed DST arithmetic coverage
1 parent f96311a commit dc20476

3 files changed

Lines changed: 193 additions & 213 deletions

File tree

‎docs/docs/addition_subtraction.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,13 @@ spring-forward transition:
100100
>>> dt.subtract(days=1, seconds=1).isoformat()
101101
'2024-03-31T02:59:59+02:00'
102102
```
103+
104+
The same ordering applies across a fall-back transition. The calendar shift
105+
first selects the normalized local time and offset, then the fixed-length shift
106+
uses elapsed time from that instant:
107+
108+
```python
109+
>>> dt = pendulum.datetime(2024, 10, 26, 4, 30, tz='Europe/Sofia')
110+
>>> dt.add(days=1, hours=-1).isoformat()
111+
'2024-10-27T03:30:00+02:00'
112+
```

‎tests/datetime/test_add.py‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,186 @@ def test_interval_over_midnight_tz():
278278
new_end = start + interval
279279

280280
assert new_end == end
281+
282+
283+
@pytest.mark.parametrize("method, sign", [("add", 1), ("subtract", -1)])
284+
@pytest.mark.parametrize(
285+
"tz, start, calendar_units, fixed_units, expected",
286+
[
287+
pytest.param(
288+
"Europe/Sofia",
289+
(2024, 4, 1, 4),
290+
{"days": -1},
291+
{"seconds": -1},
292+
"2024-03-31T02:59:59+02:00",
293+
id="spring-forward-gap",
294+
),
295+
pytest.param(
296+
"Europe/Paris",
297+
(2013, 3, 24, 2, 30),
298+
{"weeks": 1},
299+
{"hours": 1},
300+
"2013-03-31T04:30:00+02:00",
301+
id="calendar-gap-normalization",
302+
),
303+
pytest.param(
304+
"America/New_York",
305+
(2024, 2, 10, 1, 30),
306+
{"months": 1},
307+
{"hours": 25},
308+
"2024-03-11T03:30:00-04:00",
309+
id="fixed-hours-over-one-day",
310+
),
311+
pytest.param(
312+
"Europe/Paris",
313+
(2012, 10, 27, 1, 59, 59, 999999),
314+
{"years": 1},
315+
{"hours": 1, "microseconds": 1},
316+
"2013-10-27T02:00:00+01:00",
317+
id="fall-back-forward-shift",
318+
),
319+
pytest.param(
320+
"Europe/Paris",
321+
(2013, 11, 27, 2),
322+
{"months": -1},
323+
{"microseconds": -1},
324+
"2013-10-27T02:59:59.999999+02:00",
325+
id="fall-back-backward-shift",
326+
),
327+
pytest.param(
328+
"Europe/Sofia",
329+
(2024, 10, 26, 4, 30),
330+
{"days": 1},
331+
{"hours": -1},
332+
"2024-10-27T03:30:00+02:00",
333+
id="fall-back-calendar-then-elapsed",
334+
),
335+
pytest.param(
336+
"Europe/Sofia",
337+
(2024, 10, 20, 3, 30),
338+
{"weeks": 1},
339+
{"minutes": -60},
340+
"2024-10-27T03:30:00+03:00",
341+
id="ambiguous-time-fold",
342+
),
343+
pytest.param(
344+
"Europe/Sofia",
345+
(2024, 3, 30, 4),
346+
{"days": 1},
347+
{"seconds": -0.5},
348+
"2024-03-31T02:59:59.500000+02:00",
349+
id="opposite-directions-fractional-seconds",
350+
),
351+
pytest.param(
352+
"America/New_York",
353+
(2025, 11, 3, 0, 30),
354+
{"years": -1},
355+
{"hours": 2},
356+
"2024-11-03T01:30:00-05:00",
357+
id="negative-calendar-positive-elapsed",
358+
),
359+
pytest.param(
360+
"Australia/Lord_Howe",
361+
(2024, 10, 7, 2, 30),
362+
{"days": -1},
363+
{"microseconds": -1},
364+
"2024-10-06T01:59:59.999999+10:30",
365+
id="half-hour-spring-forward",
366+
),
367+
pytest.param(
368+
"Australia/Lord_Howe",
369+
(2024, 4, 14, 1),
370+
{"weeks": -1},
371+
{"hours": 1},
372+
"2024-04-07T01:30:00+10:30",
373+
id="half-hour-fall-back",
374+
),
375+
pytest.param(
376+
"Europe/Paris",
377+
(2024, 2, 29, 1, 30),
378+
{"years": 1, "months": 1, "days": 1},
379+
{"hours": 2},
380+
"2025-03-30T04:30:00+02:00",
381+
id="multiple-calendar-units",
382+
),
383+
pytest.param(
384+
"Europe/Sofia",
385+
(2024, 4, 1, 16),
386+
{"days": -1.5},
387+
{"seconds": -1},
388+
"2024-03-31T02:59:59+02:00",
389+
id="fractional-calendar-days",
390+
),
391+
pytest.param(
392+
"UTC",
393+
(2024, 4, 1, 4),
394+
{"days": -1},
395+
{"seconds": -1},
396+
"2024-03-31T03:59:59+00:00",
397+
id="utc-control",
398+
),
399+
pytest.param(
400+
None,
401+
(2024, 4, 1, 4),
402+
{"days": -1},
403+
{"seconds": -1},
404+
"2024-03-31T03:59:59",
405+
id="naive-control",
406+
),
407+
pytest.param(
408+
5.5,
409+
(2024, 1, 31, 4),
410+
{"months": 1},
411+
{"seconds": -0.25, "microseconds": -1000001},
412+
"2024-02-29T03:59:58.749999+05:30",
413+
id="fixed-offset-month-clamping",
414+
),
415+
],
416+
)
417+
def test_mixed_arithmetic_applies_calendar_before_elapsed_time(
418+
method: str,
419+
sign: int,
420+
tz: str | float | None,
421+
start: tuple[int, ...],
422+
calendar_units: dict[str, int | float],
423+
fixed_units: dict[str, int | float],
424+
expected: str,
425+
) -> None:
426+
dt = pendulum.DateTime.create(*start, tz=tz)
427+
calendar_args = {unit: sign * value for unit, value in calendar_units.items()}
428+
fixed_args = {unit: sign * value for unit, value in fixed_units.items()}
429+
430+
result = getattr(dt, method)(**calendar_args, **fixed_args)
431+
432+
assert result.isoformat() == expected
433+
assert result.tzinfo is dt.tzinfo
434+
435+
436+
@pytest.mark.parametrize(
437+
"tz, expected",
438+
[
439+
("Europe/Sofia", "2024-03-31T02:59:59.500000+02:00"),
440+
(None, "2024-03-31T03:59:59.500000"),
441+
],
442+
)
443+
def test_mixed_arithmetic_preserves_subclass(tz: str | None, expected: str) -> None:
444+
class Subclass(pendulum.DateTime):
445+
pass
446+
447+
dt = Subclass.create(2024, 4, 1, 4, tz=tz)
448+
result = dt.subtract(days=1, seconds=0.5)
449+
450+
assert type(result) is Subclass
451+
assert result.tzinfo is dt.tzinfo
452+
assert result.isoformat() == expected
453+
454+
455+
def test_naive_mixed_arithmetic_rounds_fractional_units_once() -> None:
456+
dt = pendulum.naive(2024, 4, 1, 4)
457+
days = 0.6 / 86_400_000_000
458+
seconds = 0.0000006
459+
460+
result = dt.add(days=days, seconds=seconds) # type: ignore[arg-type]
461+
462+
assert result.isoformat() == "2024-04-01T04:00:00.000001"
463+
assert result.tzinfo is None

0 commit comments

Comments
 (0)