100% Coverage for Calculation/DateTime (#1870)

* 100% Coverage for Calculation/DateTime

The code in DateTime is now completely covered.
Along the way, some errors were discovered and corrected.
- The tests which have had to be changed at the start of every year are
replaced by more robust equivalents which do not require annual changes.
- Several places in the code where Gnumeric and OpenOffice were thought to differ
from Excel do not appear to have had any justification.
I have left a comment where such code has been removed.
- Use DateTime when possible rather than date, time, or strftime functions to avoid
potential Y2038 problems.
- Some impossible code has been removed, replaced by an explanatory comment.
- NETWORKDAYS had a bug when the start date was Sunday. There had been no tests
of this condition.
- Some functions allow boolean and null arguments where a number is expected.
This is more complicated than the equivalent situations in MathTrig because
the initial date for these calculations can be Day 1 rather than Day 0.
- More testing for dates from 1900-01-01 through the fictitious
everywhere-but-Excel 1900-01-29.
    - This showed that there is an additional Excel bug - Excel evaluates
WEEKNUM(emptycell) as 0, which is not a valid result for
WEEKNUM without a second argument.
PhpSpreadsheet now duplicates this bug.
    - There is a similar and even worse bug for 1904-01-01 in 1904 calculations.
Weeknum returns 0 for this,
but returns the correct value for arguments of 0 or null.
    - DATEVALUE should accept 1900-02-29 (sigh) and relatives.
PhpSpreadsheet now duplicates this bug.
- Testing bootstrap sets default timezone. This appears to be a relic from
the releases of PHP where the unwise decision, subsequenly reversed,
was made to issue messages for
"no default timezone is set" rather than just use a sensible default.
This was a disruptive setting for some of the tests I added.
There is only one test in the entire suite which is default-timezone-dependent.
Setting and resetting of default timezone is moved to that test
(Reader/ODS/ODSTest), and out of bootstrap.
- There had been no testing of NOW() function.
- DATEVALUE test had no tests for 1904 calendar and needs some.
- DATE test changed 1900/1904 calendar in use without restoring it.
- WEEKDAY test had no tests for 1904 calendar and needs some.
    - Which revealed a bug in Shared/Date (excelToDateTimeObject was not
recognizing 1904-01-01 as valid when 1904 calendar is in use).
    - And an additional bug in that legal 1904-calendar values in the 0.0-1.0
range yielded the same "wrong" answers as 1900-calendar (see "One note" below).
Also the comment for one of the calendar-1904 tests was wrong in attempting
to identify what time of day the fraction represented.

I had wanted to break this up into a set of smaller modules, a process already
started for Engineering and MathTrig.
However the number of source code changes was sufficient that I wanted
a clean delta for this request.
If it is merged, I will work on breaking it up afterwards.

One note - Shared/Date/excelToDateTimeObject, when calendar-1900 is in use,
returns an unexpected result if its argument is between 0 and 1,
which is nominally invalid for that calendar.
It uses a base-1970 calendar in that instance. That check is not justifiable
for calendar-1904, where values in that range are legal,
so I made the check specific to calendar-1900,
and adjusted 3 1904 unit test results accordingly. However, I have to admit that
I don't understand why that check should be made even for calendar-1900.
It certainly doesn't match anything that Excel does.
I would recommend scrapping that code altogether.
If agreed, I would do this as part of the break-up into smaller modules.

Another note -
more controversially, it is clear that PhpSpreadsheet needs to support
the Excel and PHP date formats. Although it requires further study,
I am not convinced that it needs to support Unix timestamp format.
Since that is a potential source of Y2038 problems on 32-bit systems,
I would like to open a PR to deprecate the use of that format.
Please let me know if you are aware of a valid reason to continue to support it.
This commit is contained in:
oleibman 2021-02-27 11:43:22 -08:00 committed by GitHub
parent 08673b5820
commit 80a20fc991
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 536 additions and 250 deletions

View File

@ -144,26 +144,10 @@ class DateTime
*/
public static function DATETIMENOW()
{
$saveTimeZone = date_default_timezone_get();
date_default_timezone_set('UTC');
$retValue = false;
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
$retValue = (float) Date::PHPToExcel(time());
$dti = new DateTimeImmutable();
$dateArray = date_parse($dti->format('c'));
break;
case Functions::RETURNDATE_UNIX_TIMESTAMP:
$retValue = (int) time();
break;
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
$retValue = new \DateTime();
break;
}
date_default_timezone_set($saveTimeZone);
return $retValue;
return is_array($dateArray) ? self::returnIn3FormatsArray($dateArray) : Functions::VALUE();
}
/**
@ -185,27 +169,10 @@ class DateTime
*/
public static function DATENOW()
{
$saveTimeZone = date_default_timezone_get();
date_default_timezone_set('UTC');
$retValue = false;
$excelDateTime = floor(Date::PHPToExcel(time()));
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
$retValue = (float) $excelDateTime;
$dti = new DateTimeImmutable();
$dateArray = date_parse($dti->format('c'));
break;
case Functions::RETURNDATE_UNIX_TIMESTAMP:
$retValue = (int) Date::excelToTimestamp($excelDateTime);
break;
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
$retValue = Date::excelToDateTimeObject($excelDateTime);
break;
}
date_default_timezone_set($saveTimeZone);
return $retValue;
return is_array($dateArray) ? self::returnIn3FormatsArray($dateArray, true) : Functions::VALUE();
}
/**
@ -316,14 +283,8 @@ class DateTime
// Execute function
$excelDateValue = Date::formattedPHPToExcel($year, $month, $day);
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
return (float) $excelDateValue;
case Functions::RETURNDATE_UNIX_TIMESTAMP:
return (int) Date::excelToTimestamp($excelDateValue);
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
return Date::excelToDateTimeObject($excelDateValue);
}
return self::returnIn3FormatsFloat($excelDateValue);
}
/**
@ -403,8 +364,8 @@ class DateTime
}
// Execute function
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_EXCEL) {
$date = 0;
$calendar = Date::getExcelCalendar();
if ($calendar != Date::CALENDAR_WINDOWS_1900) {
@ -412,28 +373,16 @@ class DateTime
}
return (float) Date::formattedPHPToExcel($calendar, 1, $date, $hour, $minute, $second);
case Functions::RETURNDATE_UNIX_TIMESTAMP:
}
if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
return (int) Date::excelToTimestamp(Date::formattedPHPToExcel(1970, 1, 1, $hour, $minute, $second)); // -2147468400; // -2147472000 + 3600
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
$dayAdjust = 0;
if ($hour < 0) {
$dayAdjust = floor($hour / 24);
$hour = 24 - abs($hour % 24);
if ($hour == 24) {
$hour = 0;
}
} elseif ($hour >= 24) {
$dayAdjust = floor($hour / 24);
$hour = $hour % 24;
}
// RETURNDATE_PHP_DATETIME_OBJECT
// Hour has already been normalized (0-23) above
$phpDateObject = new \DateTime('1900-01-01 ' . $hour . ':' . $minute . ':' . $second);
if ($dayAdjust != 0) {
$phpDateObject->modify($dayAdjust . ' days');
}
return $phpDateObject;
}
}
/**
* DATEVALUE.
@ -462,6 +411,8 @@ class DateTime
*/
public static function DATEVALUE($dateValue = 1)
{
$dti = new DateTimeImmutable();
$baseYear = Date::getExcelCalendar();
$dateValue = trim(Functions::flattenSingleValue($dateValue), '"');
// Strip any ordinals because they're allowed in Excel (English only)
$dateValue = preg_replace('/(\d)(st|nd|rd|th)([ -\/])/Ui', '$1$3', $dateValue);
@ -470,6 +421,7 @@ class DateTime
$yearFound = false;
$t1 = explode(' ', $dateValue);
$t = '';
foreach ($t1 as &$t) {
if ((is_numeric($t)) && ($t > 31)) {
if ($yearFound) {
@ -481,10 +433,11 @@ class DateTime
$yearFound = true;
}
}
if ((count($t1) == 1) && (strpos($t, ':') !== false)) {
if (count($t1) === 1) {
// We've been fed a time value without any date
return 0.0;
} elseif (count($t1) == 2) {
return ((strpos($t, ':') === false)) ? Functions::Value() : 0.0;
}
if (count($t1) == 2) {
// We only have two parts of the date: either day/month or month/year
if ($yearFound) {
array_unshift($t1, 1);
@ -493,7 +446,7 @@ class DateTime
$t1[1] += 1900;
array_unshift($t1, 1);
} else {
$t1[] = date('Y');
$t1[] = $dti->format('Y');
}
}
}
@ -502,23 +455,13 @@ class DateTime
$PHPDateArray = date_parse($dateValue);
if (($PHPDateArray === false) || ($PHPDateArray['error_count'] > 0)) {
// If original count was 1, we've already returned.
// If it was 2, we added another.
// Therefore, neither of the first 2 stroks below can fail.
$testVal1 = strtok($dateValue, '- ');
if ($testVal1 !== false) {
$testVal2 = strtok('- ');
if ($testVal2 !== false) {
$testVal3 = strtok('- ');
if ($testVal3 === false) {
$testVal3 = strftime('%Y');
}
} else {
return Functions::VALUE();
}
} else {
return Functions::VALUE();
}
if ($testVal1 < 31 && $testVal2 < 12 && $testVal3 < 12 && strlen($testVal3) == 2) {
$testVal3 += 2000;
}
$testVal3 = strtok('- ') ?: $dti->format('Y');
self::adjustYear($testVal1, $testVal2, $testVal3);
$PHPDateArray = date_parse($testVal1 . '-' . $testVal2 . '-' . $testVal3);
if (($PHPDateArray === false) || ($PHPDateArray['error_count'] > 0)) {
$PHPDateArray = date_parse($testVal2 . '-' . $testVal1 . '-' . $testVal3);
@ -528,44 +471,126 @@ class DateTime
}
}
$retValue = Functions::Value();
if (($PHPDateArray !== false) && ($PHPDateArray['error_count'] == 0)) {
// Execute function
if ($PHPDateArray['year'] == '') {
$PHPDateArray['year'] = strftime('%Y');
}
if ($PHPDateArray['year'] < 1900) {
self::replaceIfEmpty($PHPDateArray['year'], $dti->format('Y'));
if ($PHPDateArray['year'] < $baseYear) {
return Functions::VALUE();
}
if ($PHPDateArray['month'] == '') {
$PHPDateArray['month'] = strftime('%m');
self::replaceIfEmpty($PHPDateArray['month'], $dti->format('m'));
self::replaceIfEmpty($PHPDateArray['day'], $dti->format('d'));
$PHPDateArray['hour'] = 0;
$PHPDateArray['minute'] = 0;
$PHPDateArray['second'] = 0;
$month = (int) $PHPDateArray['month'];
$day = (int) $PHPDateArray['day'];
$year = (int) $PHPDateArray['year'];
if (!checkdate($month, $day, $year)) {
return ($year === 1900 && $month === 2 && $day === 29) ? self::returnIn3FormatsFloat(60.0) : Functions::VALUE();
}
if ($PHPDateArray['day'] == '') {
$PHPDateArray['day'] = strftime('%d');
$retValue = is_array($PHPDateArray) ? self::returnIn3FormatsArray($PHPDateArray, true) : Functions::VALUE();
}
return $retValue;
}
/**
* Help reduce perceived complexity of some tests.
*
* @param mixed $value
* @param mixed $altValue
*/
private static function replaceIfEmpty(&$value, $altValue): void
{
$value = $value ?: $altValue;
}
/**
* Adjust year in ambiguous situations.
*/
private static function adjustYear(string $testVal1, string $testVal2, string &$testVal3): void
{
if (!is_numeric($testVal1) || $testVal1 < 31) {
if (!is_numeric($testVal2) || $testVal2 < 12) {
if (is_numeric($testVal3) && $testVal3 < 12) {
$testVal3 += 2000;
}
if (!checkdate($PHPDateArray['month'], $PHPDateArray['day'], $PHPDateArray['year'])) {
return Functions::VALUE();
}
$excelDateValue = floor(
Date::formattedPHPToExcel(
$PHPDateArray['year'],
$PHPDateArray['month'],
$PHPDateArray['day'],
$PHPDateArray['hour'],
$PHPDateArray['minute'],
$PHPDateArray['second']
)
);
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
return (float) $excelDateValue;
case Functions::RETURNDATE_UNIX_TIMESTAMP:
return (int) Date::excelToTimestamp($excelDateValue);
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
return new \DateTime($PHPDateArray['year'] . '-' . $PHPDateArray['month'] . '-' . $PHPDateArray['day'] . ' 00:00:00');
}
}
return Functions::VALUE();
/**
* Return result in one of three formats.
*
* @return mixed
*/
private static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false)
{
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
return new \DateTime(
$dateArray['year']
. '-' . $dateArray['month']
. '-' . $dateArray['day']
. ' ' . $dateArray['hour']
. ':' . $dateArray['minute']
. ':' . $dateArray['second']
);
}
$excelDateValue =
Date::formattedPHPToExcel(
$dateArray['year'],
$dateArray['month'],
$dateArray['day'],
$dateArray['hour'],
$dateArray['minute'],
$dateArray['second']
);
if ($retType === Functions::RETURNDATE_EXCEL) {
return $noFrac ? floor($excelDateValue) : (float) $excelDateValue;
}
// RETURNDATE_UNIX_TIMESTAMP)
return (int) Date::excelToTimestamp($excelDateValue);
}
/**
* Return result in one of three formats.
*
* @return mixed
*/
private static function returnIn3FormatsFloat(float $excelDateValue)
{
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_EXCEL) {
return $excelDateValue;
}
if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
return (int) Date::excelToTimestamp($excelDateValue);
}
// RETURNDATE_PHP_DATETIME_OBJECT
return Date::excelToDateTimeObject($excelDateValue);
}
/**
* Return result in one of three formats.
*
* @return mixed
*/
private static function returnIn3FormatsObject(\DateTime $PHPDateObject)
{
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
return $PHPDateObject;
}
if ($retType === Functions::RETURNDATE_EXCEL) {
return (float) Date::PHPToExcel($PHPDateObject);
}
// RETURNDATE_UNIX_TIMESTAMP
return (int) Date::excelToTimestamp(Date::PHPToExcel($PHPDateObject));
}
/**
@ -601,31 +626,22 @@ class DateTime
}
$PHPDateArray = date_parse($timeValue);
$retValue = Functions::VALUE();
if (($PHPDateArray !== false) && ($PHPDateArray['error_count'] == 0)) {
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_OPENOFFICE) {
$excelDateValue = Date::formattedPHPToExcel(
$PHPDateArray['year'],
$PHPDateArray['month'],
$PHPDateArray['day'],
$PHPDateArray['hour'],
$PHPDateArray['minute'],
$PHPDateArray['second']
);
} else {
// OpenOffice-specific code removed - it works just like Excel
$excelDateValue = Date::formattedPHPToExcel(1900, 1, 1, $PHPDateArray['hour'], $PHPDateArray['minute'], $PHPDateArray['second']) - 1;
}
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
return (float) $excelDateValue;
case Functions::RETURNDATE_UNIX_TIMESTAMP:
return (int) $phpDateValue = Date::excelToTimestamp($excelDateValue + 25569) - 3600;
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
return new \DateTime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
$retType = Functions::getReturnDateType();
if ($retType === Functions::RETURNDATE_EXCEL) {
$retValue = (float) $excelDateValue;
} elseif ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
$retValue = (int) $phpDateValue = Date::excelToTimestamp($excelDateValue + 25569) - 3600;
} else {
$retValue = new \DateTime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
}
}
return Functions::VALUE();
return $retValue;
}
/**
@ -980,7 +996,7 @@ class DateTime
// Execute function
$startDoW = 6 - self::WEEKDAY($startDate, 2);
if ($startDoW < 0) {
$startDoW = 0;
$startDoW = 5;
}
$endDoW = self::WEEKDAY($endDate, 2);
if ($endDoW >= 6) {
@ -1113,14 +1129,7 @@ class DateTime
}
}
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
return (float) $endDate;
case Functions::RETURNDATE_UNIX_TIMESTAMP:
return (int) Date::excelToTimestamp($endDate);
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
return Date::excelToDateTimeObject($endDate);
}
return self::returnIn3FormatsFloat($endDate);
}
/**
@ -1141,9 +1150,10 @@ class DateTime
{
$dateValue = Functions::flattenSingleValue($dateValue);
if ($dateValue === null) {
$dateValue = 1;
} elseif (is_string($dateValue = self::getDateValue($dateValue))) {
if ($dateValue === null || is_bool($dateValue)) {
return (int) $dateValue;
}
if (is_string($dateValue = self::getDateValue($dateValue))) {
return Functions::VALUE();
}
@ -1170,7 +1180,7 @@ class DateTime
* Excel Function:
* WEEKDAY(dateValue[,style])
*
* @param int $dateValue Excel date serial value (float), PHP date timestamp (integer),
* @param float|int|string $dateValue Excel date serial value (float), PHP date timestamp (integer),
* PHP DateTime object, or a standard date string
* @param int $style A number that determines the type of return value
* 1 or omitted Numbers 1 (Sunday) through 7 (Saturday).
@ -1182,6 +1192,7 @@ class DateTime
public static function WEEKDAY($dateValue = 1, $style = 1)
{
$dateValue = Functions::flattenSingleValue($dateValue);
self::nullFalseTrueToNumber($dateValue);
$style = Functions::flattenSingleValue($style);
if (!is_numeric($style)) {
@ -1191,19 +1202,19 @@ class DateTime
}
$style = floor($style);
if ($dateValue === null) {
$dateValue = 1;
} elseif (is_string($dateValue = self::getDateValue($dateValue))) {
$dateValue = self::getDateValue($dateValue);
if (is_string($dateValue)) {
return Functions::VALUE();
} elseif ($dateValue < 0.0) {
}
if ($dateValue < 0.0) {
return Functions::NAN();
}
// Execute function
$PHPDateObject = Date::excelToDateTimeObject($dateValue);
self::silly1900($PHPDateObject);
$DoW = (int) $PHPDateObject->format('w');
$firstDay = 1;
switch ($style) {
case 1:
++$DoW;
@ -1219,20 +1230,10 @@ class DateTime
if ($DoW === 0) {
$DoW = 7;
}
$firstDay = 0;
--$DoW;
break;
}
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) {
// Test for Excel's 1900 leap year, and introduce the error as required
if (($PHPDateObject->format('Y') == 1900) && ($PHPDateObject->format('n') <= 2)) {
--$DoW;
if ($DoW < $firstDay) {
$DoW += 7;
}
}
}
return $DoW;
}
@ -1298,17 +1299,26 @@ class DateTime
*/
public static function WEEKNUM($dateValue = 1, $method = self::STARTWEEK_SUNDAY)
{
$origDateValueNull = $dateValue === null;
$dateValue = Functions::flattenSingleValue($dateValue);
$method = Functions::flattenSingleValue($method);
if (!is_numeric($method)) {
return Functions::VALUE();
}
$method = (int) $method;
if (!array_key_exists($method, self::METHODARR)) {
return Functions::NaN();
}
$method = self::METHODARR[$method];
if ($dateValue === null) { // boolean not allowed
// This seems to be an additional Excel bug.
if (self::buggyWeekNum1900($method)) {
return 0;
}
//$dateValue = 1;
$dateValue = (Date::getExcelCalendar() === DATE::CALENDAR_MAC_1904) ? 0 : 1;
}
$dateValue = self::getDateValue($dateValue);
if (is_string($dateValue)) {
@ -1321,8 +1331,14 @@ class DateTime
// Execute function
$PHPDateObject = Date::excelToDateTimeObject($dateValue);
if ($method == self::STARTWEEK_MONDAY_ISO) {
self::silly1900($PHPDateObject);
return (int) $PHPDateObject->format('W');
}
if (self::buggyWeekNum1904($method, $origDateValueNull, $PHPDateObject)) {
return 0;
}
self::silly1900($PHPDateObject, '+ 5 years'); // 1905 calendar matches
$dayOfYear = $PHPDateObject->format('z');
$PHPDateObject->modify('-' . $dayOfYear . ' days');
$firstDayOfFirstWeek = $PHPDateObject->format('w');
@ -1334,6 +1350,18 @@ class DateTime
return (int) $weekOfYear;
}
private static function buggyWeekNum1900(int $method): bool
{
return $method === self::DOW_SUNDAY && Date::getExcelCalendar() === Date::CALENDAR_WINDOWS_1900;
}
private static function buggyWeekNum1904(int $method, bool $origNull, \DateTime $dateObject): bool
{
// This appears to be another Excel bug.
return $method === self::DOW_SUNDAY && Date::getExcelCalendar() === Date::CALENDAR_MAC_1904 && !$origNull && $dateObject->format('Y-m-d') === '1904-01-01';
}
/**
* ISOWEEKNUM.
*
@ -1350,17 +1378,19 @@ class DateTime
public static function ISOWEEKNUM($dateValue = 1)
{
$dateValue = Functions::flattenSingleValue($dateValue);
self::nullFalseTrueToNumber($dateValue);
if ($dateValue === null) {
$dateValue = 1;
} elseif (is_string($dateValue = self::getDateValue($dateValue))) {
$dateValue = self::getDateValue($dateValue);
if (!is_numeric($dateValue)) {
return Functions::VALUE();
} elseif ($dateValue < 0.0) {
}
if ($dateValue < 0.0) {
return Functions::NAN();
}
// Execute function
$PHPDateObject = Date::excelToDateTimeObject($dateValue);
self::silly1900($PHPDateObject);
return (int) $PHPDateObject->format('W');
}
@ -1449,12 +1479,7 @@ class DateTime
$timeValue = Functions::flattenSingleValue($timeValue);
if (!is_numeric($timeValue)) {
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_GNUMERIC) {
$testVal = strtok($timeValue, '/-: ');
if (strlen($testVal) < strlen($timeValue)) {
return Functions::VALUE();
}
}
// Gnumeric test removed - it operates like Excel
$timeValue = self::getTimeValue($timeValue);
if (is_string($timeValue)) {
return Functions::VALUE();
@ -1490,12 +1515,7 @@ class DateTime
$timeValue = $timeTester = Functions::flattenSingleValue($timeValue);
if (!is_numeric($timeValue)) {
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_GNUMERIC) {
$testVal = strtok($timeValue, '/-: ');
if (strlen($testVal) < strlen($timeValue)) {
return Functions::VALUE();
}
}
// Gnumeric test removed - it operates like Excel
$timeValue = self::getTimeValue($timeValue);
if (is_string($timeValue)) {
return Functions::VALUE();
@ -1531,12 +1551,7 @@ class DateTime
$timeValue = Functions::flattenSingleValue($timeValue);
if (!is_numeric($timeValue)) {
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_GNUMERIC) {
$testVal = strtok($timeValue, '/-: ');
if (strlen($testVal) < strlen($timeValue)) {
return Functions::VALUE();
}
}
// Gnumeric test removed - it operates like Excel
$timeValue = self::getTimeValue($timeValue);
if (is_string($timeValue)) {
return Functions::VALUE();
@ -1590,14 +1605,7 @@ class DateTime
// Execute function
$PHPDateObject = self::adjustDateByMonths($dateValue, $adjustmentMonths);
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
return (float) Date::PHPToExcel($PHPDateObject);
case Functions::RETURNDATE_UNIX_TIMESTAMP:
return (int) Date::excelToTimestamp(Date::PHPToExcel($PHPDateObject));
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
return $PHPDateObject;
}
return self::returnIn3FormatsObject($PHPDateObject);
}
/**
@ -1639,13 +1647,31 @@ class DateTime
$adjustDaysString = '-' . $adjustDays . ' days';
$PHPDateObject->modify($adjustDaysString);
switch (Functions::getReturnDateType()) {
case Functions::RETURNDATE_EXCEL:
return (float) Date::PHPToExcel($PHPDateObject);
case Functions::RETURNDATE_UNIX_TIMESTAMP:
return (int) Date::excelToTimestamp(Date::PHPToExcel($PHPDateObject));
case Functions::RETURNDATE_PHP_DATETIME_OBJECT:
return $PHPDateObject;
return self::returnIn3FormatsObject($PHPDateObject);
}
/**
* Many functions accept null/false/true argument treated as 0/0/1.
*
* @param mixed $number
*/
private static function nullFalseTrueToNumber(&$number): void
{
$number = Functions::flattenSingleValue($number);
$baseYear = Date::getExcelCalendar();
$nullVal = $baseYear === DATE::CALENDAR_MAC_1904 ? 0 : 1;
if ($number === null) {
$number = $nullVal;
} elseif (is_bool($number)) {
$number = $nullVal + (int) $number;
}
}
private static function silly1900(\DateTime $PHPDateObject, string $mod = '-1 day'): void
{
$isoDate = $PHPDateObject->format('c');
if ($isoDate < '1900-03-01') {
$PHPDateObject->modify($mod);
}
}
}

View File

@ -160,7 +160,7 @@ class Date
{
$timeZone = ($timeZone === null) ? self::getDefaultTimezone() : self::validateTimeZone($timeZone);
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) {
if ($excelTimestamp < 1.0) {
if ($excelTimestamp < 1 && self::$excelCalendar === self::CALENDAR_WINDOWS_1900) {
// Unix timestamp base date
$baseDate = new \DateTime('1970-01-01', $timeZone);
} else {

View File

@ -9,11 +9,21 @@ use PHPUnit\Framework\TestCase;
class DateTest extends TestCase
{
private $returnDateType;
private $excelCalendar;
protected function setUp(): void
{
Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
$this->returnDateType = Functions::getReturnDateType();
$this->excelCalendar = Date::getExcelCalendar();
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_WINDOWS_1900);
}
protected function tearDown(): void
{
Functions::setReturnDateType($this->returnDateType);
Date::setExcelCalendar($this->excelCalendar);
}
/**

View File

@ -2,6 +2,7 @@
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use PhpOffice\PhpSpreadsheet\Calculation\DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
@ -10,11 +11,21 @@ use PHPUnit\Framework\TestCase;
class DateValueTest extends TestCase
{
private $returnDateType;
private $excelCalendar;
protected function setUp(): void
{
Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
$this->returnDateType = Functions::getReturnDateType();
$this->excelCalendar = Date::getExcelCalendar();
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_WINDOWS_1900);
}
protected function tearDown(): void
{
Functions::setReturnDateType($this->returnDateType);
Date::setExcelCalendar($this->excelCalendar);
}
/**
@ -25,7 +36,21 @@ class DateValueTest extends TestCase
*/
public function testDATEVALUE($expectedResult, $dateValue): void
{
// Loop to avoid extraordinarily rare edge case where first calculation
// and second do not take place on same day.
do {
$dtStart = new DateTimeImmutable();
$startDay = $dtStart->format('d');
if (is_string($expectedResult)) {
$replYMD = str_replace('Y', date('Y'), $expectedResult);
if ($replYMD !== $expectedResult) {
$expectedResult = DateTime::DATEVALUE($replYMD);
}
}
$result = DateTime::DATEVALUE($dateValue);
$dtEnd = new DateTimeImmutable();
$endDay = $dtEnd->format('d');
} while ($startDay !== $endDay);
self::assertEqualsWithDelta($expectedResult, $result, 1E-8);
}
@ -55,4 +80,13 @@ class DateValueTest extends TestCase
// ... with the correct value
self::assertEquals($result->format('d-M-Y'), '31-Jan-2012');
}
public function testDATEVALUEwith1904Calendar(): void
{
Date::setExcelCalendar(Date::CALENDAR_MAC_1904);
self::assertEquals(5428, DateTime::DATEVALUE('1918-11-11'));
self::assertEquals(0, DateTime::DATEVALUE('1904-01-01'));
self::assertEquals('#VALUE!', DateTime::DATEVALUE('1903-12-31'));
self::assertEquals('#VALUE!', DateTime::DATEVALUE('1900-02-29'));
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\DateTime;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\TestCase;
class NowTest extends TestCase
{
public function testNow(): void
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Loop to avoid rare edge case where first calculation
// and second do not take place in same second.
do {
$dtStart = new DateTimeImmutable();
$startSecond = $dtStart->format('s');
$sheet->setCellValue('A1', '=NOW()');
$dtEnd = new DateTimeImmutable();
$endSecond = $dtEnd->format('s');
} while ($startSecond !== $endSecond);
//echo("\n"); var_dump($sheet->getCell('A1')->getCalculatedValue()); echo ("\n");
$sheet->setCellValue('B1', '=YEAR(A1)');
$sheet->setCellValue('C1', '=MONTH(A1)');
$sheet->setCellValue('D1', '=DAY(A1)');
$sheet->setCellValue('E1', '=HOUR(A1)');
$sheet->setCellValue('F1', '=MINUTE(A1)');
$sheet->setCellValue('G1', '=SECOND(A1)');
self::assertEquals($dtStart->format('Y'), $sheet->getCell('B1')->getCalculatedValue());
self::assertEquals($dtStart->format('m'), $sheet->getCell('C1')->getCalculatedValue());
self::assertEquals($dtStart->format('d'), $sheet->getCell('D1')->getCalculatedValue());
self::assertEquals($dtStart->format('H'), $sheet->getCell('E1')->getCalculatedValue());
self::assertEquals($dtStart->format('i'), $sheet->getCell('F1')->getCalculatedValue());
self::assertEquals($dtStart->format('s'), $sheet->getCell('G1')->getCalculatedValue());
}
}

View File

@ -9,11 +9,20 @@ use PHPUnit\Framework\TestCase;
class TimeTest extends TestCase
{
private $returnDateType;
private $calendar;
protected function setUp(): void
{
Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_WINDOWS_1900);
$this->returnDateType = Functions::getReturnDateType();
$this->calendar = Date::getExcelCalendar();
}
protected function tearDown(): void
{
Functions::setReturnDateType($this->returnDateType);
Date::setExcelCalendar($this->calendar);
}
/**
@ -23,6 +32,7 @@ class TimeTest extends TestCase
*/
public function testTIME($expectedResult, ...$args): void
{
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
$result = DateTime::TIME(...$args);
self::assertEqualsWithDelta($expectedResult, $result, 1E-8);
}
@ -52,4 +62,20 @@ class TimeTest extends TestCase
// ... with the correct value
self::assertEquals($result->format('H:i:s'), '07:30:20');
}
public function testTIME1904(): void
{
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_MAC_1904);
$result = DateTime::TIME(0, 0, 0);
self::assertEquals(0, $result);
}
public function testTIME1900(): void
{
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_WINDOWS_1900);
$result = DateTime::TIME(0, 0, 0);
self::assertEquals(0, $result);
}
}

View File

@ -3,17 +3,21 @@
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PHPUnit\Framework\TestCase;
class WeekDayTest extends TestCase
{
private $excelCalendar;
protected function setUp(): void
{
Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_WINDOWS_1900);
$this->excelCalendar = Date::getExcelCalendar();
}
protected function tearDown(): void
{
Date::setExcelCalendar($this->excelCalendar);
}
/**
@ -31,4 +35,12 @@ class WeekDayTest extends TestCase
{
return require 'tests/data/Calculation/DateTime/WEEKDAY.php';
}
public function testWEEKDAYwith1904Calendar(): void
{
Date::setExcelCalendar(Date::CALENDAR_MAC_1904);
self::assertEquals(7, DateTime::WEEKDAY('1904-01-02'));
self::assertEquals(6, DateTime::WEEKDAY('1904-01-01'));
self::assertEquals(6, DateTime::WEEKDAY(null));
}
}

View File

@ -3,17 +3,21 @@
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PHPUnit\Framework\TestCase;
class WeekNumTest extends TestCase
{
private $excelCalendar;
protected function setUp(): void
{
Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL);
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
Date::setExcelCalendar(Date::CALENDAR_WINDOWS_1900);
$this->excelCalendar = Date::getExcelCalendar();
}
protected function tearDown(): void
{
Date::setExcelCalendar($this->excelCalendar);
}
/**
@ -31,4 +35,14 @@ class WeekNumTest extends TestCase
{
return require 'tests/data/Calculation/DateTime/WEEKNUM.php';
}
public function testWEEKNUMwith1904Calendar(): void
{
Date::setExcelCalendar(Date::CALENDAR_MAC_1904);
self::assertEquals(27, DateTime::WEEKNUM('2004-07-02'));
self::assertEquals(1, DateTime::WEEKNUM('1904-01-02'));
self::assertEquals(1, DateTime::WEEKNUM(null));
// The following is a bug in Excel.
self::assertEquals(0, DateTime::WEEKNUM('1904-01-01'));
}
}

View File

@ -15,6 +15,19 @@ use PHPUnit\Framework\TestCase;
*/
class OdsTest extends TestCase
{
private $timeZone;
protected function setUp(): void
{
$this->timeZone = date_default_timezone_get();
date_default_timezone_set('UTC');
}
protected function tearDown(): void
{
date_default_timezone_set($this->timeZone);
}
/**
* @var Spreadsheet
*/
@ -153,13 +166,13 @@ class OdsTest extends TestCase
self::assertEquals(0, $firstSheet->getCell('G10')->getValue());
self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A10')->getDataType()); // Date
self::assertEquals(22269.0, $firstSheet->getCell('A10')->getValue());
self::assertEquals('19-Dec-60', $firstSheet->getCell('A10')->getFormattedValue());
self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A13')->getDataType()); // Time
self::assertEquals(25569.0625, $firstSheet->getCell('A13')->getValue());
self::assertEquals('2:30:00', $firstSheet->getCell('A13')->getFormattedValue());
self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A15')->getDataType()); // Date + Time
self::assertEquals(22269.0625, $firstSheet->getCell('A15')->getValue());
self::assertEquals('19-Dec-60 1:30:00', $firstSheet->getCell('A15')->getFormattedValue());
self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A11')->getDataType()); // Fraction

View File

@ -3,4 +3,4 @@
setlocale(LC_ALL, 'en_US.utf8');
// PHP 5.3 Compat
date_default_timezone_set('Europe/London');
//date_default_timezone_set('Europe/London');

View File

@ -20,12 +20,12 @@ return [
'1900/2/28',
],
[
'#VALUE!',
'60',
'29-02-1900',
],
// MS Excel will fail with a #VALUE return, but PhpSpreadsheet can parse this date
[
'#VALUE!',
'60',
'29th February 1900',
],
[
@ -159,30 +159,32 @@ return [
'#VALUE!',
'The 1st day of March 2007',
],
// 01/01 of the current year
// Jan 1 of the current year
[
44197,
'Y-01-01',
'1 Jan',
],
// 31/12 of the current year
// Dec 31 of the current year
[
44561,
'Y-12-31',
'31/12',
],
// Excel reads as 1st December 1931, not 31st December in current year
// Excel reads as 1st December 1931, not 31st December in current year.
// This result is locale-dependent in Excel, in a manner not
// supported by PhpSpreadsheet.
[
11658,
'12/31',
],
// 05/07 of the current year
// July 5 of the current year
[
44382,
'Y-07-05',
'5-JUL',
],
// 05/07 of the current year
// July 5 of the current year
[
44382,
'5 Jul',
'Y-07-05',
'5 July',
],
[
39783,
@ -216,6 +218,11 @@ return [
'#VALUE!',
12,
],
// implicit day of month is 1
[
40210,
'Feb-2010',
],
[
40221,
'12-Feb-2010',
@ -294,4 +301,16 @@ return [
'#VALUE!',
'ABCDEFGHIJKMNOPQRSTUVWXYZ',
],
[
'#VALUE!',
'1999',
],
['#VALUE!', '32/32'],
['#VALUE!', '1910-'],
['#VALUE!', '10--'],
['#VALUE!', '--10'],
['#VALUE!', '--1910'],
//['#VALUE!', '-JUL-1910'], We can parse this, Excel can't
['#VALUE!', '2008-08-'],
[36751, '0-08-13'],
];

View File

@ -53,4 +53,19 @@ return [
30, // Result for OpenOffice
0,
],
[
0, // Result for Excel
0, // Result for OpenOffice
null,
],
[
1, // Result for Excel
1, // Result for OpenOffice
true,
],
[
0, // Result for Excel
0, // Result for OpenOffice
false,
],
];

View File

@ -33,4 +33,14 @@ return [
'#VALUE!',
'1800-01-01',
],
['52', null],
['53', '1904-01-01'],
['52', '1900-01-01'],
['1', '1900-01-07'],
['1', '1900-01-08'],
['2', '1900-01-09'],
['9', '1900-03-04'],
['10', '1900-03-05'],
['#NUM!', '-1'],
[39, '1000'],
];

View File

@ -100,4 +100,22 @@ return [
'31-Jan-2007',
'1-Feb-2007',
],
['#VALUE!', 'ABQZ', '1-Feb-2007'],
['#VALUE!', '1-Feb-2007', 'ABQZ'],
[10, '2021-02-13', '2021-02-27'],
[10, '2021-02-14', '2021-02-27'],
[3, '2021-02-14', '2021-02-17'],
[8, '2021-02-14', '2021-02-24'],
[9, '2021-02-14', '2021-02-25'],
[10, '2021-02-14', '2021-02-26'],
[9, '2021-02-13', '2021-02-25'],
[10, '2021-02-12', '2021-02-25'],
[
'#VALUE!',
'10-Jan-1961',
'19-Dec-1960',
'25-Dec-1960',
'ABQZ',
'01-Jan-1961',
],
];

View File

@ -110,4 +110,11 @@ return [
'#NUM!',
-1,
],
[1, null],
[1, false],
[2, true],
[1, '1900-01-01'],
[7, '1900-01-01', 2],
[7, null, 2],
[7, '1900-02-05', 2],
];

View File

@ -173,4 +173,31 @@ return [
1,
'2025-12-29', 21,
],
['9', '1900-03-01'],
['2', '1900-01-07', 2],
['2', '1905-01-07', 2],
['1', '1900-01-01'],
['1', '1900-01-01', 2],
['2', '1900-01-02', 2],
['1', null, 11],
['1', null, 12],
['1', null, 13],
['1', null, 14],
['1', null, 15],
['1', null, 16],
['0', null, 17],
['1', '1905-01-01', 17],
['0', null],
['1', null, 2],
['1', '1906-01-01'],
['#VALUE!', true],
['#VALUE!', false, 21],
['52', null, 21],
['53', '1904-01-01', 21],
['52', '1900-01-01', 21],
['1', '1900-01-07', 21],
['1', '1900-01-08', 21],
['2', '1900-01-09', 21],
['9', '1900-03-04', 21],
['10', '1900-03-05', 21],
];

View File

@ -89,4 +89,20 @@ return [
],
],
],
[
44242,
'15-Feb-2021',
0,
],
[
'#VALUE!',
'5-Apr-2012',
3,
[
[
'6-Apr-2012',
'ABQZ',
],
],
],
];

View File

@ -559,5 +559,6 @@ return [
'2025-05-28',
1,
],
['#VALUE!', '2023-04-27', 'ABQZ', 1],
['#VALUE!', 'ABQZ', '2023-04-07', 1],
];

View File

@ -29,17 +29,17 @@ return [
],
// 06:00:00
[
21600,
gmmktime(6, 0, 0, 1, 1, 1904), // 32-bit safe - no Y2038 problem
0.25,
],
// 08:00.00
[
28800,
gmmktime(8, 0, 0, 1, 1, 1904), // 32-bit safe - no Y2038 problem
0.3333333333333333333,
],
// 02:57:46
// 13:02:13
[
46933,
gmmktime(13, 02, 13, 1, 1, 1904), // 32-bit safe - no Y2038 problem
0.54321,
],
];