Compare commits

..

7 Commits

Author SHA1 Message Date
Guy Sartorelli
75787d4819
Merge 84556e037ee490cd9515d43c92d51cc713bd3d8b into aa2b8c380e5e3720b92faaa849b33c6c786a80a0 2024-10-01 16:14:45 +13:00
Guy Sartorelli
84556e037e
API Refactor template layer into its own module
Includes the following large-scale changes:
- Impoved barrier between model and view layers
- Improved casting of scalar to relevant DBField types
- Improved capabilities for rendering arbitrary data in templates
2024-10-01 16:14:36 +13:00
Guy Sartorelli
aa2b8c380e
FIX Fix NavigateCommandTest and don't try to write null (#11412) 2024-10-01 13:51:33 +13:00
github-actions
27708c7ebb Merge branch '5' into 6 2024-09-30 20:34:56 +00:00
github-actions
483e944601 Merge branch '5.3' into 5 2024-09-30 20:34:55 +00:00
github-actions
d7fa53139f Merge branch '5.2' into 5.3 2024-09-30 20:34:54 +00:00
Guy Sartorelli
862a65eacc
MNT Fix unit tests (#11409) 2024-09-30 18:32:17 +13:00
18 changed files with 399 additions and 279 deletions

View File

@ -27,7 +27,7 @@ class NavigateCommand extends Command
// Handle request and output resonse body
$response = $app->handle($request);
$output->writeln($response->getBody(), OutputInterface::OUTPUT_RAW);
$output->writeln($response->getBody() ?? '', OutputInterface::OUTPUT_RAW);
// Transform HTTP status code into sensible exit code
$responseCode = $response->getStatusCode();

View File

@ -448,7 +448,7 @@ class ModelData implements Stringable
* that have been specified.
*
* @return object|DBField|null The specific object representing the field, or null if there is no
* property, method, or dynamic data available for that field or if the value is explicitly null.
* property, method, or dynamic data available for that field.
*/
public function obj(
string $fieldName,
@ -482,7 +482,7 @@ class ModelData implements Stringable
return null;
}
$value = CastingService::singleton()->cast($value, $this, $fieldName);
$value = CastingService::singleton()->cast($value, $this, $fieldName, true);
// Record in cache
if ($cache) {

View File

@ -2,6 +2,8 @@
namespace SilverStripe\View;
use LogicException;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Model\ArrayData;
@ -16,11 +18,18 @@ class CastingService
{
use Injectable;
public function cast(mixed $data, null|array|ModelData $source = null, string $fieldName = ''): ?object
/**
* Cast a value to the relevant object (usually a DBField instance) for use in the view layer.
*
* @param null|array|ModelData $source Where the data originates from. This is used both to check for casting helpers
* and to help set the value in cast DBField instances.
* @param bool $strict If true, an object will be returned even if $data is null.
*/
public function cast(mixed $data, null|array|ModelData $source = null, string $fieldName = '', bool $strict = false): ?object
{
// null is null - we shouldn't cast it to an object, because that makes it harder
// for downstream checks to know there's "no value".
if ($data === null) {
if (!$strict && $data === null) {
return null;
}
@ -39,11 +48,14 @@ class CastingService
// Explicit casts take precedence over array casting
if ($service) {
$castObject = Injector::inst()->create($service, $fieldName);
if (!ClassInfo::hasMethod($castObject, 'setValue')) {
throw new LogicException('Explicit casting service must have a setValue method.');
}
$castObject->setValue($data, $source);
return $castObject;
}
// Wrap list arrays in ModelData so templates can handle them
// Wrap arrays in ModelData so templates can handle them
if (is_array($data)) {
return array_is_list($data) ? ArrayList::create($data) : ArrayData::create($data);
}
@ -51,6 +63,9 @@ class CastingService
// Fall back to default casting
$service = $this->defaultService($data, $source, $fieldName);
$castObject = Injector::inst()->create($service, $fieldName);
if (!ClassInfo::hasMethod($castObject, 'setValue')) {
throw new LogicException('Default service must have a setValue method.');
}
$castObject->setValue($data, $source);
return $castObject;
}

View File

@ -247,7 +247,7 @@ class SSTemplateParser extends Parser implements TemplateParser
}
$res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] :
str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '');
str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? '');
}
/*!*
@ -286,9 +286,11 @@ class SSTemplateParser extends Parser implements TemplateParser
if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) {
$arguments = $sub['Call']['CallArguments']['php'];
$res['php'] .= "->$method('$property', [$arguments], 'method', true)";
$type = ViewLayerData::TYPE_METHOD;
$res['php'] .= "->$method('$property', [$arguments], '$type', true)";
} else {
$res['php'] .= "->$method('$property', [], 'property', true)";
$type = ViewLayerData::TYPE_PROPERTY;
$res['php'] .= "->$method('$property', [], '$type', true)";
}
}

View File

@ -572,7 +572,7 @@ class SSTemplateParser extends Parser implements TemplateParser
}
$res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] :
str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '');
str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? '');
}
/* Call: Method:Word ( "(" < :CallArguments? > ")" )? */
@ -777,9 +777,11 @@ class SSTemplateParser extends Parser implements TemplateParser
if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) {
$arguments = $sub['Call']['CallArguments']['php'];
$res['php'] .= "->$method('$property', [$arguments], 'method', true)";
$type = ViewLayerData::TYPE_METHOD;
$res['php'] .= "->$method('$property', [$arguments], '$type', true)";
} else {
$res['php'] .= "->$method('$property', [], 'property', true)";
$type = ViewLayerData::TYPE_PROPERTY;
$res['php'] .= "->$method('$property', [], '$type', true)";
}
}

View File

@ -6,13 +6,9 @@ use ArrayIterator;
use Countable;
use InvalidArgumentException;
use Iterator;
use LogicException;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\Model\ModelData;
use SilverStripe\ORM\FieldType\DBBoolean;
use SilverStripe\ORM\FieldType\DBText;
use SilverStripe\ORM\FieldType\DBFloat;
use SilverStripe\ORM\FieldType\DBInt;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\FieldType\DBField;
/**
@ -226,26 +222,6 @@ class SSViewer_Scope
) = end($this->itemStack);
}
public function getObj(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): ?ViewLayerData
{
$overlay = $this->getOverlay($name, $arguments);
if ($overlay !== null) {
return $overlay;
}
// @TODO caching
$on = $this->getCurrentItem();
if ($on && isset($on->$name)) {
if ($type === 'method') {
return $on->$name(...$arguments);
}
// property
return $on->$name;
}
return $this->getUnderlay($name, $arguments);
}
/**
* Set scope to an intermediate value, which will be used for getting output later on.
*/
@ -444,20 +420,61 @@ class SSViewer_Scope
return $retval === null ? '' : $retval->__toString();
}
/**
* Get the value to pass as an argument to a method.
*/
public function getValueAsArgument(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): mixed
{
$retval = null;
if ($this->hasOverlay($name)) {
$retval = $this->getOverlay($name, $arguments, true);
} else {
// @TODO caching
$on = $this->getCurrentItem();
if ($on && isset($on->$name)) {
$retval = $on->getRawDataValue($name, $type, $arguments);
}
if ($retval === null) {
$retval = $this->getUnderlay($name, $arguments, true);
}
}
if ($retval instanceof DBField) {
$retval = $retval->getValue(); // Workaround because we're still calling obj in ViewLayerData
}
$this->resetLocalScope();
return $retval;
}
/**
* Check if the current item in scope has a value for the named field.
*/
public function hasValue(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): bool
{
// return $this->getCurrentItem()->hasValue($name, $arguments); // eww
// Kinda need a combination of the above, plus checking over-and-underlays.
$obj = $this->getObj($name, $arguments, $type, $cache, $cacheName);
$this->resetLocalScope();
if (!$obj) {
return false;
// @TODO: look for ways to remove the need to call hasValue (e.g. using isset($this->getCurrentItem()->$name) and an equivalent for over/underlays)
$retval = null;
$overlay = $this->getOverlay($name, $arguments);
if ($overlay && $overlay->hasDataValue()) {
$retval = true;
}
// @TODO: look for ways to remove the need to call this method (e.g. using isset($this->getCurrentItem()->$name) and an equivalent for over/underlays)
return $obj->hasValue();
if ($retval === null) {
$on = $this->getCurrentItem();
if ($on) {
$retval = $on->hasDataValue($name, $arguments);
}
}
if (!$retval) {
$underlay = $this->getUnderlay($name, $arguments);
$retval = $underlay && $underlay->hasDataValue();
}
$this->resetLocalScope();
return $retval;
}
/**
@ -594,21 +611,46 @@ class SSViewer_Scope
);
}
private function getOverlay(string $property, array $args)
private function getObj(string $name, array $arguments = [], string $type = '', bool $cache = false, ?string $cacheName = null): ?ViewLayerData
{
if ($this->hasOverlay($name)) {
return $this->getOverlay($name, $arguments);
}
// @TODO caching
$on = $this->getCurrentItem();
if ($on && isset($on->$name)) {
if ($type === ViewLayerData::TYPE_METHOD) {
return $on->$name(...$arguments);
}
// property
return $on->$name;
}
return $this->getUnderlay($name, $arguments);
}
private function hasOverlay(string $property): bool
{
$result = $this->processTemplateOverride($property, $this->overlay);
return array_key_exists('value', $result);
}
private function getOverlay(string $property, array $args, bool $getRaw = false): mixed
{
$result = $this->processTemplateOverride($property, $this->overlay);
if (array_key_exists('value', $result)) {
return $this->getInjectedValue($result, $property, $args);
return $this->getInjectedValue($result, $property, $args, $getRaw);
}
return null;
}
private function getUnderlay(string $property, array $args)
private function getUnderlay(string $property, array $args, bool $getRaw = false): mixed
{
// Check for a presenter-specific override
$result = $this->processTemplateOverride($property, $this->underlay);
if (array_key_exists('value', $result)) {
return $this->getInjectedValue($result, $property, $args);
return $this->getInjectedValue($result, $property, $args, $getRaw);
}
// Then for iterator-specific overrides
@ -628,7 +670,7 @@ class SSViewer_Scope
$implementor->iteratorProperties(0, 1);
}
return $this->getInjectedValue($source, $property, $args);
return $this->getInjectedValue($source, $property, $args, $getRaw);
}
// And finally for global overrides
@ -636,15 +678,20 @@ class SSViewer_Scope
return $this->getInjectedValue(
SSViewer_Scope::$globalProperties[$property],
$property,
$args
$args,
$getRaw
);
}
return null;
}
private function getInjectedValue(array|TemplateGlobalProvider|TemplateIteratorProvider $source, string $property, array $params)
{
private function getInjectedValue(
array|TemplateGlobalProvider|TemplateIteratorProvider $source,
string $property,
array $params,
bool $getRaw = false
) {
// Look up the value - either from a callable, or from a directly provided value
$value = null;
if (isset($source['callable'])) {
@ -657,6 +704,20 @@ class SSViewer_Scope
);
}
return ViewLayerData::create($value);
if ($value === null) {
return null;
}
// TemplateGlobalProviders can provide an explicit service to cast to which works outside of the regular cast flow
if (!$getRaw && isset($source['casting'])) {
$castObject = Injector::inst()->create($source['casting'], $property);
if (!ClassInfo::hasMethod($castObject, 'setValue')) {
throw new LogicException('Explicit cast from template global provider must have a setValue method.');
}
$castObject->setValue($value);
$value = $castObject;
}
return $getRaw ? $value : ViewLayerData::create($value);
}
}

View File

@ -4,6 +4,7 @@ namespace SilverStripe\View;
use BadMethodCallException;
use Countable;
use InvalidArgumentException;
use IteratorAggregate;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injectable;
@ -15,10 +16,16 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
{
use Injectable;
private mixed $data;
public const TYPE_PROPERTY = 'property';
public const TYPE_METHOD = 'method';
private object $data;
public function __construct(mixed $data, mixed $source = null, string $name = '')
{
if ($data === null) {
throw new InvalidArgumentException('$data must not be null');
}
if ($data instanceof ViewLayerData) {
$data = $data->data;
} else {
@ -27,18 +34,26 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
$this->data = $data;
}
/**
* Needed so we can rewind in SSViewer_Scope::next() after getting itemIteratorTotal without throwing an exception.
* @TODO see if we can remove the need for this
*/
public function count(): int
{
// This will throw an exception if the data item isn't Countable,
// but we have to have this so we can rewind in SSViewer_Scope::next()
// after getting itemIteratorTotal without throwing an exception.
// This could be avoided if we just return $this->data->getIterator() in
// the getIterator() method (or omit that method entirely and let it be
// handled with __call()) but then any time you loop you're using objects
// that aren't ViewLayerData objects and therefore won't be cast or
// escaped correctly by Twig.
if (is_countable($this->data)) {
return count($this->data);
}
if (ClassInfo::hasMethod($this->data, 'getIterator')) {
return count($this->data->getIterator());
}
if (ClassInfo::hasMethod($this->data, 'count')) {
return $this->data->count();
}
if (isset($this->data->count)) {
return $this->data->count;
}
return 0;
}
public function getIterator(): Traversable
{
@ -51,26 +66,12 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
if (!is_iterable($iterator)) {
$iterator = $this->data->getIterator();
}
$source = $this->data instanceof ModelData ? $this->data : null;
foreach ($iterator as $item) {
yield ViewLayerData::create($item, $this->data);
yield $item === null ? null : ViewLayerData::create($item, $source);
}
}
// temporary fix - need to remove later. Can't rely on this 'cause other engines won't be calling it
public function hasValue(?string $name = null, array $arguments = []): bool
{
if ($name) {
if ($this->data instanceof ModelData) {
return $this->data->hasValue($name, $arguments);
}
return isset($this->$name);
}
if ($this->data instanceof ModelData) {
return $this->data->exists();
}
return (bool) $this->data;
}
public function __isset(string $name): bool
{
// Might be worth reintroducing the way ss template engine checks if lists/countables "exist" here,
@ -86,32 +87,61 @@ class ViewLayerData implements IteratorAggregate, Stringable, Countable
public function __get(string $name): ?ViewLayerData
{
if ($this->data instanceof ModelData) { // temporary while I move things across.
$value = $this->data->obj($name);
} else {
$value = isset($this->data->$name) ? $this->data->$name : null;
}
if ($value === null) {
return null;
}
return ViewLayerData::create($value, $this->data, $name);
$value = $this->getRawDataValue($name, ViewLayerData::TYPE_PROPERTY);
$source = $this->data instanceof ModelData ? $this->data : null;
return ViewLayerData::create($value, $source, $name); // @TODO maybe not return this here, but wrap it again in the next layer? This may not play nicely with twig when passing values into args?
}
public function __call(string $name, array $arguments = []): ?ViewLayerData
{
if ($this->data instanceof ModelData) { // temporary while I move things across.
$value = $this->data->obj($name, $arguments);
} else {
$value = ClassInfo::hasMethod($this->data, $name) ? $this->data->$name(...$arguments) : null;
}
if ($value === null) {
return null;
}
return ViewLayerData::create($value, $this->data, $name);
$value = $this->getRawDataValue($name, ViewLayerData::TYPE_METHOD, $arguments);
$source = $this->data instanceof ModelData ? $this->data : null;
return ViewLayerData::create($value, $source, $name); // @TODO maybe not return this here, but wrap it again in the next layer? This may not play nicely with twig when passing values into args?
}
public function __toString(): string
{
if ($this->data instanceof ModelData) {
return $this->data->forTemplate();
}
return (string) $this->data;
}
// @TODO We need this right now for the ss template engine, but need to check if
// we can rely on it, since twig won't be calling this at all
public function hasDataValue(?string $name = null, array $arguments = []): bool
{
if ($name) {
if ($this->data instanceof ModelData) {
return $this->data->hasValue($name, $arguments);
}
return isset($this->$name);
}
if ($this->data instanceof ModelData) {
return $this->data->exists();
}
return (bool) $this->data;
}
// @TODO We need this right now for the ss template engine, but need to check if
// we can rely on it, since twig won't be calling this at all
public function getRawDataValue(string $name, string $type, array $arguments = []): mixed
{
if ($type === ViewLayerData::TYPE_PROPERTY) {
if ($this->data instanceof ModelData) { // temporary while I move things across.
return $this->data->obj($name);
}
return isset($this->data->$name) ? $this->data->$name : null;
}
if ($type === ViewLayerData::TYPE_METHOD) {
// If it's not a property it's a method
if ($this->data instanceof ModelData) { // temporary while I move things across.
return $this->data->obj($name, $arguments);
} elseif (ClassInfo::hasMethod($this->data, $name) || method_exists($this->data, '__call')) {
return $this->data->$name(...$arguments);
}
}
throw new InvalidArgumentException('$type must be one of the TYPE_* constant values');
}
}

View File

@ -13,7 +13,7 @@ use Symfony\Component\Console\Output\BufferedOutput;
class NavigateCommandTest extends SapphireTest
{
protected $usesDatabase = false;
protected $usesDatabase = true;
public static function provideExecute(): array
{

View File

@ -12,7 +12,8 @@ class DateFieldDisabledTest extends SapphireTest
protected function setUp(): void
{
parent::setUp();
i18n::set_locale('en_NZ');
// Set to an explicit locale so project-level locale swapping doesn't affect tests
i18n::set_locale('en_US');
DBDatetime::set_mock_now('2011-02-01 8:34:00');
}
@ -22,7 +23,7 @@ class DateFieldDisabledTest extends SapphireTest
$actual = DateField_Disabled::create('Test')
->setValue('2011-02-01')
->Field();
$expected = '<span class="readonly" id="Test">1/02/2011 (today)</span>';
$expected = '<span class="readonly" id="Test">Feb 1, 2011 (today)</span>';
$this->assertEquals($expected, $actual);
// Test today's date with time
@ -38,14 +39,14 @@ class DateFieldDisabledTest extends SapphireTest
$actual = DateField_Disabled::create('Test')
->setValue('2011-01-27')
->Field();
$expected = '<span class="readonly" id="Test">27/01/2011, 5 days ago</span>';
$expected = '<span class="readonly" id="Test">Jan 27, 2011, 5 days ago</span>';
$this->assertEquals($expected, $actual);
// Test future
$actual = DateField_Disabled::create('Test')
->setValue('2011-02-06')
->Field();
$expected = '<span class="readonly" id="Test">6/02/2011, in 5 days</span>';
$expected = '<span class="readonly" id="Test">Feb 6, 2011, in 5 days</span>';
$this->assertEquals($expected, $actual);
}

View File

@ -21,7 +21,8 @@ class DatetimeFieldTest extends SapphireTest
protected function setUp(): void
{
parent::setUp();
i18n::set_locale('en_NZ');
// Set to an explicit locale so project-level locale swapping doesn't affect tests
i18n::set_locale('en_US');
// Fix now to prevent race conditions
DBDatetime::set_mock_now('2010-04-04');
$this->timezone = date_default_timezone_get();
@ -141,14 +142,14 @@ class DatetimeFieldTest extends SapphireTest
$datetimeField
->setHTML5(false)
->setLocale('en_NZ');
->setLocale('de_DE');
$datetimeField->setSubmittedValue('29/03/2003 11:00:00 pm');
$this->assertEquals($datetimeField->dataValue(), '2003-03-29 23:00:00');
$datetimeField->setSubmittedValue('29/03/2003 23:00:00');
$this->assertEquals('2003-03-29 23:00:00', $datetimeField->dataValue());
// Some localisation packages exclude the ',' in default medium format
$this->assertMatchesRegularExpression(
'#29/03/2003(,)? 11:00:00 (PM|pm)#',
'#29.03.2003(,)? 23:00:00#',
$datetimeField->Value(),
'User value is formatted, and in user timezone'
);

View File

@ -21,7 +21,8 @@ class DBDateTest extends SapphireTest
$this->oldError = error_reporting();
// Validate setup
assert(date_default_timezone_get() === 'UTC');
i18n::set_locale('en_NZ');
// Set to an explicit locale so project-level locale swapping doesn't affect tests
i18n::set_locale('en_US');
}
protected function tearDown(): void
@ -49,42 +50,42 @@ class DBDateTest extends SapphireTest
public function testNiceDate()
{
$this->assertEquals(
'31/03/2008',
'Mar 31, 2008',
DBField::create_field('Date', 1206968400)->Nice(),
"Date->Nice() works with timestamp integers"
);
$this->assertEquals(
'30/03/2008',
'Mar 30, 2008',
DBField::create_field('Date', 1206882000)->Nice(),
"Date->Nice() works with timestamp integers"
);
$this->assertEquals(
'31/03/2008',
'Mar 31, 2008',
DBField::create_field('Date', '1206968400')->Nice(),
"Date->Nice() works with timestamp strings"
);
$this->assertEquals(
'30/03/2008',
'Mar 30, 2008',
DBField::create_field('Date', '1206882000')->Nice(),
"Date->Nice() works with timestamp strings"
);
$this->assertEquals(
'4/03/2003',
'Mar 4, 2003',
DBField::create_field('Date', '4.3.2003')->Nice(),
"Date->Nice() works with D.M.YYYY format"
);
$this->assertEquals(
'4/03/2003',
'Mar 4, 2003',
DBField::create_field('Date', '04.03.2003')->Nice(),
"Date->Nice() works with DD.MM.YYYY format"
);
$this->assertEquals(
'4/03/2003',
'Mar 4, 2003',
DBField::create_field('Date', '2003-3-4')->Nice(),
"Date->Nice() works with YYYY-M-D format"
);
$this->assertEquals(
'4/03/2003',
'Mar 4, 2003',
DBField::create_field('Date', '2003-03-04')->Nice(),
"Date->Nice() works with YYYY-MM-DD format"
);
@ -108,7 +109,7 @@ class DBDateTest extends SapphireTest
{
// iso8601 expects year first, but support year last
$this->assertEquals(
'4/03/2003',
'Mar 4, 2003',
DBField::create_field('Date', '04-03-2003')->Nice(),
"Date->Nice() works with DD-MM-YYYY format"
);
@ -153,32 +154,32 @@ class DBDateTest extends SapphireTest
public function testLongDate()
{
$this->assertEquals(
'31 March 2008',
'March 31, 2008',
DBField::create_field('Date', 1206968400)->Long(),
"Date->Long() works with numeric timestamp"
);
$this->assertEquals(
'31 March 2008',
'March 31, 2008',
DBField::create_field('Date', '1206968400')->Long(),
"Date->Long() works with string timestamp"
);
$this->assertEquals(
'30 March 2008',
'March 30, 2008',
DBField::create_field('Date', 1206882000)->Long(),
"Date->Long() works with numeric timestamp"
);
$this->assertEquals(
'30 March 2008',
'March 30, 2008',
DBField::create_field('Date', '1206882000')->Long(),
"Date->Long() works with numeric timestamp"
);
$this->assertEquals(
'3 April 2003',
'April 3, 2003',
DBField::create_field('Date', '2003-4-3')->Long(),
"Date->Long() works with YYYY-M-D"
);
$this->assertEquals(
'3 April 2003',
'April 3, 2003',
DBField::create_field('Date', '3.4.2003')->Long(),
"Date->Long() works with D.M.YYYY"
);
@ -187,7 +188,7 @@ class DBDateTest extends SapphireTest
public function testFull()
{
$this->assertEquals(
'Monday, 31 March 2008',
'Monday, March 31, 2008',
DBField::create_field('Date', 1206968400)->Full(),
"Date->Full() works with timestamp integers"
);

View File

@ -15,7 +15,8 @@ class DBDatetimeTest extends SapphireTest
protected function setUp(): void
{
parent::setUp();
i18n::set_locale('en_NZ');
// Set to an explicit locale so project-level locale swapping doesn't affect tests
i18n::set_locale('en_US');
}
public function testNowWithSystemDate()
@ -127,23 +128,23 @@ class DBDatetimeTest extends SapphireTest
$date = DBDatetime::create_field('Datetime', '2001-12-11 22:10:59');
// note: Some localisation packages exclude the ',' in default medium format
i18n::set_locale('en_NZ');
$this->assertMatchesRegularExpression('#11/12/2001(,)? 10:10 PM#i', $date->Nice());
i18n::set_locale('de_DE');
$this->assertMatchesRegularExpression('#11.12.2001(,)? 22:10#i', $date->Nice());
i18n::set_locale('en_US');
$this->assertMatchesRegularExpression('#Dec 11(,)? 2001(,)? 10:10 PM#i', $date->Nice());
$this->assertMatchesRegularExpression('#Dec 11(,)? 2001(,)? 10:10\hPM#iu', $date->Nice());
}
public function testDate()
{
$date = DBDatetime::create_field('Datetime', '2001-12-31 22:10:59');
$this->assertEquals('31/12/2001', $date->Date());
$this->assertEquals('Dec 31, 2001', $date->Date());
}
public function testTime()
{
$date = DBDatetime::create_field('Datetime', '2001-12-31 22:10:59');
$this->assertMatchesRegularExpression('#10:10:59 PM#i', $date->Time());
$this->assertMatchesRegularExpression('#10:10:59\hPM#iu', $date->Time());
}
public function testTime24()

View File

@ -49,12 +49,12 @@ class DBTimeTest extends SapphireTest
public function testNice()
{
$time = DBTime::create_field('Time', '17:15:55');
$this->assertMatchesRegularExpression('#5:15:55 PM#i', $time->Nice());
$this->assertMatchesRegularExpression('#5:15:55\hPM#iu', $time->Nice());
}
public function testShort()
{
$time = DBTime::create_field('Time', '17:15:55');
$this->assertMatchesRegularExpression('#5:15 PM#i', $time->Short());
$this->assertMatchesRegularExpression('#5:15\hPM#iu', $time->Short());
}
}

View File

@ -197,20 +197,6 @@ class EndsWithFilterTest extends SapphireTest
'modifiers' => [],
'matches' => false,
],
// These will both evaluate to true because the __toString() method just returns the class name.
// We're testing this scenario because ArrayList might contain arbitrary values
[
'filterValue' => new ArrayData(['SomeField' => 'some value']),
'matchValue' => new ArrayData(['SomeField' => 'some value']),
'modifiers' => [],
'matches' => true,
],
[
'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']),
'matchValue' => new ArrayData(['SomeField' => 'some value']),
'modifiers' => [],
'matches' => true,
],
// case insensitive
[
'filterValue' => 'somevalue',

View File

@ -197,20 +197,6 @@ class PartialMatchFilterTest extends SapphireTest
'modifiers' => [],
'matches' => false,
],
// These will both evaluate to true because the __toString() method just returns the class name.
// We're testing this scenario because ArrayList might contain arbitrary values
[
'filterValue' => new ArrayData(['SomeField' => 'some value']),
'matchValue' => new ArrayData(['SomeField' => 'some value']),
'modifiers' => [],
'matches' => true,
],
[
'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']),
'matchValue' => new ArrayData(['SomeField' => 'some value']),
'modifiers' => [],
'matches' => true,
],
// case insensitive
[
'filterValue' => 'somevalue',

View File

@ -197,20 +197,6 @@ class StartsWithFilterTest extends SapphireTest
'modifiers' => [],
'matches' => false,
],
// These will both evaluate to true because the __toString() method just returns the class name.
// We're testing this scenario because ArrayList might contain arbitrary values
[
'filterValue' => new ArrayData(['SomeField' => 'some value']),
'matchValue' => new ArrayData(['SomeField' => 'some value']),
'modifiers' => [],
'matches' => true,
],
[
'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']),
'matchValue' => new ArrayData(['SomeField' => 'some value']),
'modifiers' => [],
'matches' => true,
],
// case insensitive
[
'filterValue' => 'somevalue',

View File

@ -360,8 +360,9 @@ SS;
'z<div></div>z',
$this->render('$SSViewerTest_GlobalThatTakesArguments($SSViewerTest_GlobalHTMLFragment)')
);
// Don't escape value when passing into a method call
$this->assertEquals(
'z&lt;div&gt;&lt;/div&gt;z',
'z<div></div>z',
$this->render('$SSViewerTest_GlobalThatTakesArguments($SSViewerTest_GlobalHTMLEscaped)')
);
}
@ -1118,57 +1119,59 @@ after'
public function testIncludeWithArguments()
{
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments %>'),
'<p>[out:Arg1]</p><p>[out:Arg2]</p><p>[out:Arg2.Count]</p>'
'<p>[out:Arg1]</p><p>[out:Arg2]</p><p>[out:Arg2.Count]</p>',
$this->render('<% include SSViewerTestIncludeWithArguments %>')
);
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments Arg1=A %>'),
'<p>A</p><p>[out:Arg2]</p><p>[out:Arg2.Count]</p>'
'<p>A</p><p>[out:Arg2]</p><p>[out:Arg2.Count]</p>',
$this->render('<% include SSViewerTestIncludeWithArguments Arg1=A %>')
);
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments Arg1=A, Arg2=B %>'),
'<p>A</p><p>B</p><p></p>'
'<p>A</p><p>B</p><p></p>',
$this->render('<% include SSViewerTestIncludeWithArguments Arg1=A, Arg2=B %>')
);
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments Arg1=A Bare String, Arg2=B Bare String %>'),
'<p>A Bare String</p><p>B Bare String</p><p></p>'
'<p>A Bare String</p><p>B Bare String</p><p></p>',
$this->render('<% include SSViewerTestIncludeWithArguments Arg1=A Bare String, Arg2=B Bare String %>')
);
$this->assertEquals(
'<p>A</p><p>Bar</p><p></p>',
$this->render(
'<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=$B %>',
new ArrayData(['B' => 'Bar'])
),
'<p>A</p><p>Bar</p><p></p>'
)
);
$this->assertEquals(
'<p>A</p><p>Bar</p><p></p>',
$this->render(
'<% include SSViewerTestIncludeWithArguments Arg1="A" %>',
new ArrayData(['Arg1' => 'Foo', 'Arg2' => 'Bar'])
),
'<p>A</p><p>Bar</p><p></p>'
)
);
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=0 %>'),
'<p>A</p><p>0</p><p></p>'
'<p>A</p><p>0</p><p></p>',
$this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=0 %>')
);
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=false %>'),
'<p>A</p><p></p><p></p>'
'<p>A</p><p></p><p></p>',
$this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=false %>')
);
$this->assertEquals(
$this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=null %>'),
'<p>A</p><p></p><p></p>'
'<p>A</p><p></p><p></p>',
// Note Arg2 is explicitly overridden with null
$this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=null %>')
);
$this->assertEquals(
'SomeArg - Foo - Bar - SomeArg',
$this->render(
'<% include SSViewerTestIncludeScopeInheritanceWithArgsInLoop Title="SomeArg" %>',
new ArrayData(
@ -1179,19 +1182,19 @@ after'
]
)]
)
),
'SomeArg - Foo - Bar - SomeArg'
)
);
$this->assertEquals(
'A - B - A',
$this->render(
'<% include SSViewerTestIncludeScopeInheritanceWithArgsInWith Title="A" %>',
new ArrayData(['Item' => new ArrayData(['Title' =>'B'])])
),
'A - B - A'
)
);
$this->assertEquals(
'A - B - C - B - A',
$this->render(
'<% include SSViewerTestIncludeScopeInheritanceWithArgsInNestedWith Title="A" %>',
new ArrayData(
@ -1202,11 +1205,11 @@ after'
]
)]
)
),
'A - B - C - B - A'
)
);
$this->assertEquals(
'A - A - A',
$this->render(
'<% include SSViewerTestIncludeScopeInheritanceWithUpAndTop Title="A" %>',
new ArrayData(
@ -1217,8 +1220,7 @@ after'
]
)]
)
),
'A - A - A'
)
);
$data = new ArrayData(
@ -2202,7 +2204,66 @@ EOC;
}
}
public function testCallsWithArguments()
public static function provideCallsWithArguments(): array
{
return [
[
'template' => '$Level.output(1)',
'expected' => '1-1',
],
[
'template' => '$Nest.Level.output($Set.First.Number)',
'expected' => '2-1',
],
[
'template' => '<% with $Set %>$Up.Level.output($First.Number)<% end_with %>',
'expected' => '1-1',
],
[
'template' => '<% with $Set %>$Top.Nest.Level.output($First.Number)<% end_with %>',
'expected' => '2-1',
],
[
'template' => '<% loop $Set %>$Up.Nest.Level.output($Number)<% end_loop %>',
'expected' => '2-12-22-32-42-5',
],
[
'template' => '<% loop $Set %>$Top.Level.output($Number)<% end_loop %>',
'expected' => '1-11-21-31-41-5',
],
[
'template' => '<% with $Nest %>$Level.output($Top.Set.First.Number)<% end_with %>',
'expected' => '2-1',
],
[
'template' => '<% with $Level %>$output($Up.Set.Last.Number)<% end_with %>',
'expected' => '1-5',
],
[
'template' => '<% with $Level.forWith($Set.Last.Number) %>$output("hi")<% end_with %>',
'expected' => '5-hi',
],
[
'template' => '<% loop $Level.forLoop($Set.First.Number) %>$Number<% end_loop %>',
'expected' => '!0',
],
[
'template' => '<% with $Nest %>
<% with $Level.forWith($Up.Set.First.Number) %>$output("hi")<% end_with %>
<% end_with %>',
'expected' => '1-hi',
],
[
'template' => '<% with $Nest %>
<% loop $Level.forLoop($Top.Set.Last.Number) %>$Number<% end_loop %>
<% end_with %>',
'expected' => '!0!1!2!3!4',
],
];
}
#[DataProvider('provideCallsWithArguments')]
public function testCallsWithArguments(string $template, string $expected): void
{
$data = new ArrayData(
[
@ -2222,29 +2283,8 @@ EOC;
]
);
$tests = [
'$Level.output(1)' => '1-1',
'$Nest.Level.output($Set.First.Number)' => '2-1',
'<% with $Set %>$Up.Level.output($First.Number)<% end_with %>' => '1-1',
'<% with $Set %>$Top.Nest.Level.output($First.Number)<% end_with %>' => '2-1',
'<% loop $Set %>$Up.Nest.Level.output($Number)<% end_loop %>' => '2-12-22-32-42-5',
'<% loop $Set %>$Top.Level.output($Number)<% end_loop %>' => '1-11-21-31-41-5',
'<% with $Nest %>$Level.output($Top.Set.First.Number)<% end_with %>' => '2-1',
'<% with $Level %>$output($Up.Set.Last.Number)<% end_with %>' => '1-5',
'<% with $Level.forWith($Set.Last.Number) %>$output("hi")<% end_with %>' => '5-hi',
'<% loop $Level.forLoop($Set.First.Number) %>$Number<% end_loop %>' => '!0',
'<% with $Nest %>
<% with $Level.forWith($Up.Set.First.Number) %>$output("hi")<% end_with %>
<% end_with %>' => '1-hi',
'<% with $Nest %>
<% loop $Level.forLoop($Top.Set.Last.Number) %>$Number<% end_loop %>
<% end_with %>' => '!0!1!2!3!4',
];
foreach ($tests as $template => $expected) {
$this->assertEquals($expected, trim($this->render($template, $data) ?? ''));
}
}
public function testRepeatedCallsAreCached()
{

View File

@ -2,23 +2,78 @@
namespace SilverStripe\View\Tests\SSViewerTest;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\Model\ModelData;
use ReflectionClass;
use SilverStripe\Dev\TestOnly;
use SilverStripe\View\SSViewer_Scope;
use Stringable;
/**
* A test fixture that will echo back the template item
*/
class TestFixture extends ModelData
class TestFixture implements TestOnly, Stringable
{
protected $name;
private ?string $name;
public function __construct($name = null)
{
$this->name = $name;
parent::__construct();
}
private function argedName($fieldName, $arguments)
public function __call(string $name, array $arguments = []): static|array|null
{
return $this->getValue($name, $arguments);
}
public function __get(string $name): static|array|null
{
return $this->getValue($name);
}
public function __isset(string $name): bool
{
if (preg_match('/NotSet/i', $name)) {
return false;
}
$reflectionScope = new ReflectionClass(SSViewer_Scope::class);
$globalProperties = $reflectionScope->getStaticPropertyValue('globalProperties');
if (array_key_exists($name, $globalProperties)) {
return false;
}
return true;
}
public function __toString(): string
{
if (preg_match('/NotSet/i', $this->name ?? '')) {
return '';
}
if (preg_match('/Raw/i', $this->name ?? '')) {
return $this->name ?? '';
}
return '[out:' . $this->name . ']';
}
private function getValue(string $name, array $arguments = []): static|array|null
{
$childName = $this->argedName($name, $arguments);
// Special field name Loop### to create a list
if (preg_match('/^Loop([0-9]+)$/', $name ?? '', $matches)) {
$output = [];
for ($i = 0; $i < $matches[1]; $i++) {
$output[] = new TestFixture($childName);
}
return $output;
}
if (preg_match('/NotSet/i', $name)) {
return null;
}
return new TestFixture($childName);
}
private function argedName(string $fieldName, array $arguments): string
{
$childName = $this->name ? "$this->name.$fieldName" : $fieldName;
if ($arguments) {
@ -27,51 +82,4 @@ class TestFixture extends ModelData
return $childName;
}
}
public function obj(
string $fieldName,
array $arguments = [],
bool $cache = false,
?string $cacheName = null
): ?object {
$childName = $this->argedName($fieldName, $arguments);
// Special field name Loop### to create a list
if (preg_match('/^Loop([0-9]+)$/', $fieldName ?? '', $matches)) {
$output = new ArrayList();
for ($i = 0; $i < $matches[1]; $i++) {
$output->push(new TestFixture($childName));
}
return $output;
} else {
if (preg_match('/NotSet/i', $fieldName ?? '')) {
return null;
} else {
return new TestFixture($childName);
}
}
}
public function XML_val(string $fieldName, array $arguments = [], bool $cache = false): string
{
if (preg_match('/NotSet/i', $fieldName ?? '')) {
return '';
} else {
if (preg_match('/Raw/i', $fieldName ?? '')) {
return $fieldName;
} else {
return '[out:' . $this->argedName($fieldName, $arguments) . ']';
}
}
}
public function hasValue(string $fieldName, array $arguments = [], bool $cache = true): bool
{
return (bool)$this->XML_val($fieldName, $arguments);
}
public function forTemplate(): string
{
return '[out:' . $this->name . ']';
}
}