Skip to content

Commit f16315c

Browse files
committed
Improve tests
1 parent bd6c1e1 commit f16315c

4 files changed

Lines changed: 122 additions & 99 deletions

File tree

composer.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
"php": ">=7.2"
2020
},
2121
"require-dev": {
22-
"symfony/intl": "^6.4",
2322
"symfony/phpunit-bridge": "^6.4",
2423
"symfony/var-dumper": "^5.4|^6.4"
2524
},

src/Time/Resources/stubs/Time/Duration.php

Lines changed: 103 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111

1212
namespace Time;
1313

14-
use function dd;
15-
1614
if (\PHP_VERSION_ID < 80600) {
1715
final class Duration
1816
{
@@ -38,15 +36,11 @@ final class Duration
3836
* @var non-empty-string
3937
*/
4038
private const REGEXP_ISO8601 = '@^
41-
(?<sign>[+-])?
4239
PT
43-
(?=(?:\d+H|\d+M|\d+(?:[\.,]\d{1,9})?S)) # look ahead to avoid PT without argument
40+
(?=(?:\d+H|\d+M|\d+S)) # look ahead to avoid PT without argument
4441
(?:(?<hour>\d+)H)?
4542
(?:(?<minute>\d+)M)?
46-
(?:
47-
(?<second>\d+)
48-
(?:[\.,](?<nanosecond>\d{1,9}))
49-
?S)?
43+
(?:(?<second>\d+)?S)?
5044
$@x';
5145

5246
/**
@@ -56,43 +50,47 @@ private function __construct(
5650
public readonly int $seconds,
5751
public readonly int $nanoseconds,
5852
public readonly bool $negative,
59-
) {
53+
) {}
54+
55+
/**
56+
* Create a duration representing $seconds seconds and $nanoseconds nanoseconds. Neither parameter
57+
* may be negative. $nanoseconds must be less than 1_000_000_000 (the number of nanoseconds in a
58+
* second).
59+
*
60+
* This constructor creates a Duration from its “atomic” components.
61+
*/
62+
public static function fromSeconds(int $seconds, int $nanoseconds = 0): self
63+
{
6064
if ($seconds < 0) {
61-
throw new \ValueError('$seconds must be a non negative integer.');
65+
throw new \ValueError(__METHOD__.'(): Argument #1 ($seconds) must be greater than or equal to 0');
6266
}
6367

6468
if ($nanoseconds < 0) {
65-
throw new \ValueError('$nanoseconds must be a non negative integer.');
69+
throw new \ValueError(__METHOD__.'(): Argument #2 ($nanoseconds) must be greater than or equal to 0');
6670
}
6771

68-
if ($seconds >= self::MAX_SECONDS) {
69-
throw new TimeException('$seconds must be between 0 and '.self::MAX_SECONDS.' seconds (roughly 292 years)');
72+
if ($seconds > self::MAX_SECONDS) {
73+
throw new TimeException('The maximum representable range is 9_223_372_035 seconds (roughly 292 years)');
7074
}
7175

7276
if ($nanoseconds >= self::NANOS_PER_SECOND) {
73-
throw new TimeException('$nanoseconds must be between 0 and 999999999 nanoseconds.');
77+
throw new \ValueError(__METHOD__.'(): Argument #2 ($nanoseconds) must be less than 1_000_000_000');
7478
}
75-
}
7679

77-
/**
78-
* Create a duration representing $seconds seconds and $nanoseconds nanoseconds. Neither parameter
79-
* may be negative. $nanoseconds must be less than 1_000_000_000 (the number of nanoseconds in a
80-
* second).
81-
*
82-
* This constructor creates a Duration from its “atomic” components.
83-
*/
84-
public static function fromSeconds(int $seconds, int $nanoseconds = 0): self
85-
{
8680
return new self($seconds, $nanoseconds, false);
8781
}
8882

8983
/**
90-
* Create a duration representing $nanoseconds nano-seconds. $nanoseconds must not be negative.
84+
* Create a duration representing $nanoseconds nanoseconds. $nanoseconds must not be negative.
9185
*
9286
* @param non-negative-int $nanoseconds
9387
*/
9488
public static function fromNanoseconds(int $nanoseconds): self
9589
{
90+
if ($nanoseconds < 0) {
91+
throw new \ValueError(__METHOD__.'(): Argument #1 ($nanoseconds) must be greater than or equal to 0');
92+
}
93+
9694
$seconds = intdiv($nanoseconds, self::NANOS_PER_SECOND);
9795
$nanoseconds = $nanoseconds % self::NANOS_PER_SECOND;
9896

@@ -108,7 +106,11 @@ public static function fromNanoseconds(int $nanoseconds): self
108106
*/
109107
public static function fromMicroseconds(int $microseconds): self
110108
{
111-
return self::fromNanoseconds(self::convertTo($microseconds, 1_000, 'microseconds', 'nanoseconds'));
109+
if ($microseconds < 0) {
110+
throw new \ValueError(__METHOD__.'(): Argument #1 ($microseconds) must be greater than or equal to 0');
111+
}
112+
113+
return self::fromNanoseconds(self::convertTo($microseconds, 1_000, 'microseconds', 'nanoseconds', __METHOD__));
112114
}
113115

114116
/**
@@ -123,10 +125,10 @@ public static function fromMicroseconds(int $microseconds): self
123125
*
124126
* @return non-negative-int
125127
*/
126-
private static function convertTo(int $value, int $factor, string $fromUnit, string $toUnit): int
128+
private static function convertTo(int $value, int $factor, string $fromUnit, string $toUnit, string $method): int
127129
{
128130
if ($value < 0) {
129-
throw new \ValueError("$value must be a non negative integer.");
131+
throw new \ValueError("$method Argument #1 ($fromUnit) must be greater than or equal to 0");
130132
}
131133

132134
if ($value > intdiv(\PHP_INT_MAX, $factor)) {
@@ -145,7 +147,11 @@ private static function convertTo(int $value, int $factor, string $fromUnit, str
145147
*/
146148
public static function fromMilliseconds(int $milliseconds): self
147149
{
148-
return self::fromNanoseconds(self::convertTo($milliseconds, 1_000_000, 'milliseconds', 'nanoseconds'));
150+
if ($milliseconds < 0) {
151+
throw new \ValueError(__METHOD__.'(): Argument #1 ($milliseconds) must be greater than or equal to 0');
152+
}
153+
154+
return self::fromNanoseconds(self::convertTo($milliseconds, 1_000_000, 'milliseconds', 'nanoseconds', __METHOD__));
149155
}
150156

151157
/**
@@ -157,7 +163,11 @@ public static function fromMilliseconds(int $milliseconds): self
157163
*/
158164
public static function fromMinutes(int $minutes): self
159165
{
160-
return self::fromSeconds(self::convertTo($minutes, 60, 'minutes', 'seconds'));
166+
if ($minutes < 0) {
167+
throw new \ValueError(__METHOD__.'(): Argument #1 ($minutes) must be greater than or equal to 0');
168+
}
169+
170+
return self::fromSeconds(self::convertTo($minutes, 60, 'minutes', 'seconds', __METHOD__));
161171
}
162172

163173
/**
@@ -169,7 +179,11 @@ public static function fromMinutes(int $minutes): self
169179
*/
170180
public static function fromHours(int $hours): self
171181
{
172-
return self::fromSeconds(self::convertTo($hours, 3_600, 'hours', 'seconds'));
182+
if ($hours < 0) {
183+
throw new \ValueError(__METHOD__.'(): Argument #1 ($hours) must be greater than or equal to 0');
184+
}
185+
186+
return self::fromSeconds(self::convertTo($hours, 3_600, 'hours', 'seconds', __METHOD__));
173187
}
174188

175189
/**
@@ -178,22 +192,27 @@ public static function fromHours(int $hours): self
178192
*/
179193
public static function fromIso8601DurationString(string $specification): self
180194
{
181-
if (preg_match(self::REGEXP_ISO8601, $specification, $parts) !== 1) {
182-
throw new TimeException("The submitted duration `$specification` is invalid or contains unsupported ISO 8601 duration components.");
195+
if (!preg_match(self::REGEXP_ISO8601, $specification, $parts)) {
196+
if (!str_starts_with($specification, 'PT')) {
197+
throw new TimeException("Failed to create a Time\Duration: The ISO8601 duration string may only contain the period (P) aspect");
198+
}
199+
200+
if (strpbrk($specification, 'HMS') === false) {
201+
throw new TimeException("Failed to create a Time\Duration: The ISO8601 duration string may only contain the time (T) aspect");
202+
}
203+
204+
throw new TimeException("Failed to create a Time\Duration: The ISO8601 duration string could not be parsed");
183205
}
184206

185-
$seconds = self::convertTo((int) ($parts['hour'] ?? 0), 3_600, 'hours', 'seconds')
186-
+ self::convertTo((int)($parts['minute'] ?? 0), 60, 'minutes', 'seconds')
207+
$seconds = self::convertTo((int) ($parts['hour'] ?? 0), 3_600, 'hours', 'seconds', __METHOD__)
208+
+ self::convertTo((int)($parts['minute'] ?? 0), 60, 'minutes', 'seconds', __METHOD__)
187209
+ (int) ($parts['second'] ?? 0);
188210

189211
if ($seconds > self::MAX_SECONDS) {
190-
throw new TimeException('Can not convert `'.$specification.'` specification; the resulting value exceeds the supported duration range.');
212+
throw new TimeException('The maximum representable range is 9_223_372_035 seconds (roughly 292 years)');
191213
}
192214

193-
$nanoseconds = (int) (str_pad($parts['nanosecond'] ?? '0', 9, '0', \STR_PAD_RIGHT));
194-
$negative = '-' === ($parts['sign'] ?? '') && ($seconds !== 0 || $nanoseconds !== 0);
195-
196-
return new self($seconds, $nanoseconds, $negative);
215+
return new self($seconds, 0, false);
197216
}
198217

199218
/**
@@ -226,28 +245,43 @@ public function absolute(): self
226245
*/
227246
public function add(self $duration): self
228247
{
229-
/* (+x) + (-y) == (+x) - (+y) */
230-
if (!$this->negative && $duration->negative) {
231-
return $this->sub($duration->negate());
248+
$signedParts = static function (Duration $duration): array {
249+
$seconds = $duration->seconds;
250+
$nanoseconds = $duration->nanoseconds;
251+
if ($duration->negative) {
252+
$seconds *= -1;
253+
$nanoseconds *= -1;
254+
}
255+
256+
return [$seconds, $nanoseconds];
257+
};
258+
259+
[$thisSeconds, $thisNanoseconds] = $signedParts($this);
260+
[$thatSeconds, $thatNanoseconds] = $signedParts($duration);
261+
262+
$seconds = $thisSeconds + $thatSeconds;
263+
$nanoseconds = $thisNanoseconds + $thatNanoseconds;
264+
265+
if ($nanoseconds >= self::NANOS_PER_SECOND || $nanoseconds <= -self::NANOS_PER_SECOND) {
266+
$seconds += intdiv($nanoseconds, self::NANOS_PER_SECOND);
267+
$nanoseconds %= self::NANOS_PER_SECOND;
232268
}
233269

234-
/* (-x) + (+y) == (+y) - (+x) */
235-
if ($this->negative && !$duration->negative) {
236-
return $duration->sub($this->negate());
270+
if ($seconds > 0 && $nanoseconds < 0) {
271+
--$seconds;
272+
$nanoseconds += self::NANOS_PER_SECOND;
237273
}
238274

239-
/* (-x) + (-y) = -((+x) + (+y)) */
240-
if ($this->negative) {
241-
return $this->negate()->add($duration->negate())->negate();
275+
if ($seconds < 0 && $nanoseconds > 0) {
276+
++$seconds;
277+
$nanoseconds -= self::NANOS_PER_SECOND;
242278
}
243279

244-
$seconds = $this->seconds + $duration->seconds;
245-
$nanoseconds = $this->nanoseconds + $duration->nanoseconds;
246-
247-
$seconds += intdiv($nanoseconds, self::NANOS_PER_SECOND);
248-
$nanoseconds = $nanoseconds % self::NANOS_PER_SECOND;
249-
250-
return self::fromSeconds($seconds, $nanoseconds);
280+
return new self(
281+
abs($seconds),
282+
abs($nanoseconds),
283+
($seconds < 0 || (0 === $seconds && $nanoseconds < 0))
284+
);
251285
}
252286

253287
/**
@@ -257,34 +291,7 @@ public function add(self $duration): self
257291
*/
258292
public function sub(self $duration): self
259293
{
260-
/* (+x) - (-y) == (+x) + (+y) */
261-
if (!$this->negative && $duration->negative) {
262-
return $this->add($duration->negate());
263-
}
264-
265-
/* (-x) - (+y) == -((+x) + (+y)) */
266-
if ($this->negative && !$duration->negative) {
267-
return $this->negate()->add($duration)->negate();
268-
}
269-
270-
/* (-x) - (-y) == (-x) + (+y) */
271-
if ($this->negative) {
272-
return $this->add($duration->negate());
273-
}
274-
275-
if (self::compare($this, $duration) < 0) {
276-
/* (+x) - (+y) = -((+y) - (+x)) */
277-
return $duration->sub($this)->negate();
278-
}
279-
280-
$seconds = $this->seconds - $duration->seconds;
281-
$nanoseconds = $this->nanoseconds - $duration->nanoseconds;
282-
if ($nanoseconds < 0) {
283-
$nanoseconds += self::NANOS_PER_SECOND;
284-
$seconds--;
285-
}
286-
287-
return self::fromSeconds($seconds, $nanoseconds);
294+
return $this->add($duration->negate());
288295
}
289296

290297
/**
@@ -298,6 +305,14 @@ public function multiplyBy(int $factor): self
298305
throw new \ValueError('$factor must not be negative');
299306
}
300307

308+
if ($factor === 0) {
309+
return new self(0, 0, false);
310+
}
311+
312+
if ($factor === 1) {
313+
return $this;
314+
}
315+
301316
$seconds = $this->seconds * $factor;
302317
$nanoseconds = $this->nanoseconds * $factor;
303318

@@ -320,6 +335,10 @@ public function divideBy(int $divisor): self
320335
throw new \ValueError('$divisor must be positive');
321336
}
322337

338+
if ($divisor === 1) {
339+
return $this;
340+
}
341+
323342
$seconds = intdiv($this->seconds, $divisor);
324343
$nanoseconds = $this->nanoseconds + (($this->seconds % $divisor) * self::NANOS_PER_SECOND);
325344
$nanoseconds = intdiv($nanoseconds, $divisor);

tests/Time/DurationTest.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public static function invalidNanosecondsProvider(): iterable
7171
{
7272
return [
7373
'negative value' => [-1, \ValueError::class],
74-
'too large value' => [1000000000, TimeException::class],
74+
'too large value' => [1000000000, \ValueError::class],
7575
];
7676
}
7777

@@ -146,12 +146,10 @@ public static function validIso8601Provider(): array
146146
{
147147
return [
148148
'1 second' => ['PT1S', 1, 0, false],
149-
'1.5 seconds (dot)' => ['PT1.5S', 1, 500000000, false],
150-
'1.5 seconds (comma)' => ['PT1,5S', 1, 500000000, false],
151149
'2 minutes 30 seconds' => ['PT2M30S', 150, 0, false],
152-
'full precision' => ['PT1H2M3.123456789S', 3723, 123456789, false],
153-
'negative duration' => ['-PT5S', 5, 0, true],
154-
'negative zero normalizes to positive' => ['-PT0S', 0, 0, false],
150+
'zero seconds' => ['PT0S', 0, 0, false],
151+
'zero minutes' => ['PT0M', 0, 0, false],
152+
'zero hours' => ['PT0H', 0, 0, false],
155153
];
156154
}
157155

@@ -176,6 +174,11 @@ public static function invalidIso8601Provider(): array
176174
'empty duration' => ['P'],
177175
'not an ISO 8601 duration' => ['foo'],
178176
'unsupported year component' => ['PT1Y'],
177+
'1.5 seconds (dot)' => ['PT1.5S', 1, 500000000, false],
178+
'1.5 seconds (comma)' => ['PT1,5S', 1, 500000000, false],
179+
'full precision' => ['PT1H2M3.123456789S', 3723, 123456789, false],
180+
'negative duration' => ['-PT5S', 5, 0, true],
181+
'negative zero normalizes to positive' => ['-PT0S', 0, 0, false],
179182
];
180183
}
181184

0 commit comments

Comments
 (0)