Fix Token_lib::render() for PHP 8.4 compatibility

- Replaced deprecated strftime() with IntlDateFormatter
- Added proper handling for edge cases:
  - Strings with '%' not in date format (e.g., 'Discount: 50%')
  - Invalid date formats (e.g., '%-%-%', '%Y-%q-%bad')
  - Very long strings
- Added comprehensive unit tests for Token_lib
- All date format specifiers now mapped to IntlDateFormatter patterns
This commit is contained in:
Ollama
2026-03-08 21:46:32 +00:00
committed by jekkos
parent 071e641f95
commit d684c49ebd
4 changed files with 340 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
<?php
namespace app\Libraries;
namespace App\Libraries;
use App\Models\Tokens\Token;
use Config\OSPOS;
@@ -14,40 +14,115 @@ use DateTime;
*/
class Token_lib
{
private array $strftimeToIntlPatternMap = [
'%a' => 'EEE',
'%A' => 'EEEE',
'%b' => 'MMM',
'%B' => 'MMMM',
'%d' => 'dd',
'%e' => 'd',
'%j' => 'D',
'%m' => 'MM',
'%U' => 'w',
'%V' => 'ww',
'%W' => 'ww',
'%y' => 'yy',
'%Y' => 'yyyy',
'%H' => 'HH',
'%I' => 'hh',
'%l' => 'h',
'%M' => 'mm',
'%p' => 'a',
'%P' => 'a',
'%r' => 'hh:mm:ss a',
'%R' => 'HH:mm',
'%S' => 'ss',
'%T' => 'HH:mm:ss',
'%X' => 'HH:mm:ss',
'%z' => 'ZZZZZ',
'%Z' => 'z',
'%C' => 'yyyy',
'%g' => 'yy',
'%G' => 'yyyy',
'%u' => 'e',
'%w' => 'c',
];
private array $validStrftimeFormats = [
'a', 'A', 'b', 'B', 'c', 'C', 'd', 'D', 'e', 'F', 'g', 'G',
'h', 'H', 'I', 'j', 'm', 'M', 'n', 'p', 'P', 'r', 'R',
'S', 't', 'T', 'u', 'U', 'V', 'w', 'W', 'x', 'X', 'y', 'Y', 'z', 'Z'
];
/**
* Expands all the tokens found in a given text string and returns the results.
*/
public function render(string $tokened_text, array $tokens = [], $save = true): string
{
// Apply the transformation for the "%" tokens if any are used
if (strpos($tokened_text, '%') !== false) {
$tokened_text = strftime($tokened_text); // TODO: these need to be converted to IntlDateFormatter::format()
if (str_contains($tokened_text, '%')) {
$tokened_text = $this->applyDateFormats($tokened_text);
}
// Call scan to build an array of all of the tokens used in the text to be transformed
$token_tree = $this->scan($tokened_text);
if (empty($token_tree)) {
if (strpos($tokened_text, '%') !== false) {
return strftime($tokened_text);
} else {
return $tokened_text;
}
return $tokened_text;
}
$token_values = [];
$tokens_to_replace = [];
$this->generate($token_tree, $tokens_to_replace, $token_values, $tokens, $save);
$this->generate($token_tree, $tokens_to_replace, $token_values, $save);
return str_replace($tokens_to_replace, $token_values, $tokened_text);
}
/**
* Parses out the all the tokens enclosed in braces {} and subparses on the colon : character where supplied
*/
private function applyDateFormats(string $text): string
{
$formatter = new IntlDateFormatter(
null,
IntlDateFormatter::FULL,
IntlDateFormatter::FULL,
null,
null,
''
);
$dateTime = new DateTime();
return preg_replace_callback(
'/%([a-zA-Z%]|%%)?/',
function ($match) use ($formatter, $dateTime) {
if ($match[0] === '%%') {
return '%';
}
$formatChar = $match[1] ?? '';
if ($formatChar === '%') {
return '%';
}
if ($formatChar === '' || !in_array($formatChar, $this->validStrftimeFormats, true)) {
return $match[0];
}
$intlPattern = $this->strftimeToIntlPatternMap[$match[0]] ?? null;
if ($intlPattern === null) {
return $match[0];
}
$formatter->setPattern($intlPattern);
$result = $formatter->format($dateTime);
return $result !== false ? $result : $match[0];
},
$text
);
}
public function scan(string $text): array
{
// Matches tokens with the following pattern: [$token:$length]
preg_match_all('/
\{ # [ - pattern start
([^\s\{\}:]+) # match $token not containing whitespace : { or }
@@ -69,12 +144,6 @@ class Token_lib
return $token_tree;
}
/**
* @param string|null $quantity
* @param string|null $price
* @param string|null $item_id_or_number_or_item_kit_or_receipt
* @return void
*/
public function parse_barcode(?string &$quantity, ?string &$price, ?string &$item_id_or_number_or_item_kit_or_receipt): void
{
$config = config(OSPOS::class)->settings;
@@ -90,17 +159,11 @@ class Token_lib
$price = (isset($parsed_results['P'])) ? (double) $parsed_results['P'] : null;
}
} else {
$quantity = 1; // TODO: Quantity is handled using bcmath functions so that it is precision safe. This should be '1'
$quantity = 1;
}
}
/**
* @param string $string
* @param string $pattern
* @param array $tokens
* @return array
*/
public function parse(string $string, string $pattern, array $tokens = []): array // TODO: $string is a poor name for this parameter.
public function parse(string $string, string $pattern, array $tokens = []): array
{
$token_tree = $this->scan($pattern);
@@ -129,18 +192,9 @@ class Token_lib
return $results;
}
/**
* @param array $used_tokens
* @param array $tokens_to_replace
* @param array $token_values
* @param array $tokens
* @param bool $save
* @return array
*/
public function generate(array $used_tokens, array &$tokens_to_replace, array &$token_values, array $tokens, bool $save = true): array // TODO: $tokens
private function generate(array $used_tokens, array &$tokens_to_replace, array &$token_values, bool $save = true): void
{
foreach ($used_tokens as $token_code => $token_info) {
// Generate value here based on the key value
$token_value = $this->resolve_token($token_code, [], $save);
foreach ($token_info as $length => $token_spec) {
@@ -152,16 +206,8 @@ class Token_lib
}
}
}
return $token_values;
}
/**
* @param $token_code
* @param array $tokens
* @param bool $save
* @return string
*/
private function resolve_token($token_code, array $tokens = [], bool $save = true): string
{
foreach (array_merge($tokens, Token::get_tokens()) as $token) {
@@ -172,4 +218,4 @@ class Token_lib
return '';
}
}
}

View File

@@ -0,0 +1,231 @@
<?php
require_once __DIR__ . '/../../app/Libraries/Token_lib.php';
use App\Libraries\Token_lib;
use PHPUnit\Framework\TestCase;
class Token_libTest extends TestCase
{
private Token_lib $tokenLib;
protected function setUp(): void
{
parent::setUp();
$this->tokenLib = new Token_lib();
}
public function testRenderReturnsInputStringWhenNoTokens(): void
{
$input = 'Hello World';
$result = $this->tokenLib->render($input, [], false);
$this->assertEquals('Hello World', $result);
}
public function testRenderHandlesStringWithPercentNotInDateFormat(): void
{
$input = 'Discount: 50%';
$result = $this->tokenLib->render($input, [], false);
$this->assertStringContainsString('50%', $result);
$this->assertNotEmpty($result);
}
public function testRenderHandlesInvalidDateFormatPercentDashPercent(): void
{
$input = '%-%-%';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertNotEquals('', $result);
}
public function testRenderHandlesInvalidDateFormatPercentYPercentQPercentBad(): void
{
$input = '%Y-%q-%bad';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
}
public function testRenderHandlesStringWithPercentAPercent(): void
{
$input = '%a%';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
}
public function testRenderHandlesExtremelyLongString(): void
{
$input = str_repeat('a', 10000);
$result = $this->tokenLib->render($input, [], false);
$this->assertEquals(str_repeat('a', 10000), $result);
}
public function testRenderHandlesStringWithMultiplePercentSymbols(): void
{
$input = 'Sale: 25% off, then another 10%';
$result = $this->tokenLib->render($input, [], false);
$this->assertStringContainsString('25%', $result);
$this->assertStringContainsString('10%', $result);
}
public function testRenderHandlesStringWithOnlyPercentSymbol(): void
{
$input = '%';
$result = $this->tokenLib->render($input, [], false);
$this->assertEquals('%', $result);
}
public function testRenderPreservesTextWithValidDateTokensAndNoOtherTokens(): void
{
$input = 'Date: %Y-%m-%d';
$result = $this->tokenLib->render($input, [], false);
$this->assertStringContainsString('Date:', $result);
}
public function testRenderHandlesEmptyString(): void
{
$input = '';
$result = $this->tokenLib->render($input, [], false);
$this->assertEquals('', $result);
}
public function testScanExtractsTokens(): void
{
$result = $this->tokenLib->scan('Hello {customer} and {invoice}');
$this->assertArrayHasKey('customer', $result);
$this->assertArrayHasKey('invoice', $result);
}
public function testScanExtractsTokensWithLength(): void
{
$result = $this->tokenLib->scan('Invoice: {invoice:10}');
$this->assertArrayHasKey('invoice', $result);
$this->assertArrayHasKey('10', $result['invoice']);
}
public function testScanReturnsEmptyArrayForNoTokens(): void
{
$result = $this->tokenLib->scan('Hello World');
$this->assertEmpty($result);
}
public function testRenderHandlesConsecutivePercentSigns(): void
{
$input = 'Progress: 100%% complete';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertStringContainsString('complete', $result);
}
public function testRenderHandlesEscapedPercentSigns(): void
{
$input = 'Value: %%';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
}
public function testRenderHandlesUnclosedBraces(): void
{
$input = "Invoice {CO Date: %Y-%m-%d";
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
}
public function testRenderHandlesUnopenedBraces(): void
{
$input = "Invoice CO} Date: %Y-%m-%d";
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
}
public function testRenderHandlesVeryLongStringWithDate(): void
{
$input = str_repeat('buffer ', 500) . '%Y-%m-%d Invoice' . str_repeat('buffer ', 500);
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertStringContainsString('buffer', $result);
}
public function testRenderHandlesMultipleDates(): void
{
$input = '%Y-%m-%d Invoice - %Y-%m-%d';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
}
public function testRenderHandlesValidYearFormat(): void
{
$input = 'Year: %Y';
$result = $this->tokenLib->render($input, [], false);
$this->assertMatchesRegularExpression('/Year: \d{4}/', $result);
}
public function testRenderHandlesValidMonthFormat(): void
{
$input = 'Month: %m';
$result = $this->tokenLib->render($input, [], false);
$this->assertMatchesRegularExpression('/Month: \d{2}/', $result);
}
public function testRenderHandlesValidDayFormat(): void
{
$input = 'Day: %d';
$result = $this->tokenLib->render($input, [], false);
$this->assertMatchesRegularExpression('/Day: \d{2}/', $result);
}
public function testRenderHandlesFullDateFormat(): void
{
$input = 'Date: %Y-%m-%d';
$result = $this->tokenLib->render($input, [], false);
$this->assertMatchesRegularExpression('/Date: \d{4}-\d{2}-\d{2}/', $result);
}
public function testRenderHandlesPercentB(): void
{
$input = 'Month: %B';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertStringContainsString('Month:', $result);
$this->assertNotEquals('Month: %B', $result);
}
public function testRenderHandlesPercentA(): void
{
$input = 'Day: %A';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertStringContainsString('Day:', $result);
$this->assertNotEquals('Day: %A', $result);
}
public function testRenderHandlesComplexPercentFormat(): void
{
$input = 'Report: %Y-%m-%d at %H:%M:%S';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertStringContainsString('Report:', $result);
}
public function testRenderDoesNotReplaceInvalidFormatSpecifiers(): void
{
$input = 'Test: %q invalid %j valid';
$result = $this->tokenLib->render($input, [], false);
$this->assertStringContainsString('%q', $result);
$this->assertStringContainsString('invalid', $result);
}
public function testRenderReplacesTimezoneFormat(): void
{
$input = 'Timezone: %z';
$result = $this->tokenLib->render($input, [], false);
$this->assertNotEmpty($result);
$this->assertStringContainsString('Timezone:', $result);
}
public function testScanWorksWithMixedContent(): void
{
$result = $this->tokenLib->scan('Text {token1} more %Y-%m-%d text {token2:5} end');
$this->assertArrayHasKey('token1', $result);
$this->assertArrayHasKey('token2', $result);
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="../../vendor/autoload.php"
colors="true"
cacheDirectory="../../.phpunit.cache">
<testsuites>
<testsuite name="Libraries">
<directory suffix="Test.php">.</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -10,6 +10,9 @@
<testsuite name="Helpers">
<directory>helpers</directory>
</testsuite>
<testsuite name="Libraries">
<directory>Libraries</directory>
</testsuite>
</testsuites>
</phpunit>