[BUGFIX] Let form framework finisher parseOption respect arrays 20/55820/23
authorRalf Zimmermann <ralf.zimmermann@tritum.de>
Tue, 20 Feb 2018 09:17:49 +0000 (10:17 +0100)
committerFrank Naegler <frank.naegler@typo3.org>
Wed, 18 Apr 2018 12:21:44 +0000 (14:21 +0200)
The method "parseOption()" can now handle arrays. This is necessary if a
finisher option references a form element value through an
identifier like "someOption: '{<elemenIdentifier>}'" and if
the form element value is an array (e.g. MultiCheckbox).
Furthermore "parseOption()" resolves form element references
within localized option values.

Resolves: #83477
Resolves: #82715
Releases: master, 8.7
Change-Id: I15bcdb1d7799a174e48330de91a444735250bfa1
Reviewed-on: https://review.typo3.org/55820
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Stefan Froemken <froemken@gmail.com>
Tested-by: Stefan Froemken <froemken@gmail.com>
Reviewed-by: Frank Naegler <frank.naegler@typo3.org>
Tested-by: Frank Naegler <frank.naegler@typo3.org>
typo3/sysext/form/Classes/Domain/Finishers/AbstractFinisher.php
typo3/sysext/form/Tests/Unit/Domain/Finishers/AbstractFinisherTest.php

index 3164068..c001657 100644 (file)
@@ -20,6 +20,8 @@ namespace TYPO3\CMS\Form\Domain\Finishers;
 use TYPO3\CMS\Core\Utility\ArrayUtility;
 use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
 use TYPO3\CMS\Extbase\Reflection\ObjectAccess;
+use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
+use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
 use TYPO3\CMS\Form\Service\TranslationService;
 use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
 
@@ -173,65 +175,178 @@ abstract class AbstractFinisher implements FinisherInterface
             return null;
         }
 
-        if (is_array($optionValue) || is_bool($optionValue)) {
-            return $optionValue;
-        }
-
-        if ($optionValue instanceof \Closure) {
+        if (!is_string($optionValue) && !is_array($optionValue)) {
             return $optionValue;
         }
 
         $formRuntime = $this->finisherContext->getFormRuntime();
+        $optionValue = $this->substituteRuntimeReferences($optionValue, $formRuntime);
 
-        // You can encapsulate a option value with {}.
-        // This enables you to access every getable property from the
-        // TYPO3\CMS\Form\Domain\Runtime\FormRuntime.
-        //
-        // For example: {formState.formValues.<elemenIdentifier>}
-        // or {<elemenIdentifier>}
-        //
-        // Both examples are equal to "$formRuntime->getFormState()->getFormValues()[<elemenIdentifier>]"
-        // If the value is not a string nothing will be replaced.
-        // There is a special option value '{__currentTimestamp}'.
-        // This will be replaced with the current timestamp.
-        $optionValue = preg_replace_callback('/{([^}]+)}/', function ($match) use ($formRuntime) {
-            if ($match[1] === '__currentTimestamp') {
-                $value = time();
-            } else {
-                // try to resolve the path '{...}' within the FormRuntime
-                $value = ObjectAccess::getPropertyPath($formRuntime, $match[1]);
-                if ($value === null) {
-                    // try to resolve the path '{...}' within the FinisherVariableProvider
-                    $value = ObjectAccess::getPropertyPath(
-                        $this->finisherContext->getFinisherVariableProvider(),
-                        $match[1]
-                    );
-                }
-            }
-            if (!is_string($value) && !is_numeric($value)) {
-                $value = '{' . $match[1] . '}';
+        if (is_string($optionValue)) {
+            $translationOptions = isset($this->options['translation']) && \is_array($this->options['translation'])
+                                ? $this->options['translation']
+                                : [];
+
+            $optionValue = $this->translateFinisherOption(
+                $optionValue,
+                $formRuntime,
+                $optionName,
+                $optionValue,
+                $translationOptions
+            );
+
+            $optionValue = $this->substituteRuntimeReferences($optionValue, $formRuntime);
+        }
+
+        if (empty($optionValue)) {
+            if ($defaultValue !== null) {
+                $optionValue = $defaultValue;
             }
-            return $value;
-        }, $optionValue);
+        }
+        return $optionValue;
+    }
 
-        $renderingOptions = isset($this->options['translation']) ?? \is_array($this->options['translation'])
-                            ? $this->options['translation']
-                            : [];
+    /**
+     * Wraps TranslationService::translateFinisherOption to recursively
+     * invoke all array items of resolved form state values or nested
+     * finisher option configuration settings.
+     *
+     * @param string|array $subject
+     * @param FormRuntime $formRuntime
+     * @param string $optionName
+     * @param string|array $optionValue
+     * @param array $translationOptions
+     * @return array|string
+     */
+    protected function translateFinisherOption(
+        $subject,
+        FormRuntime $formRuntime,
+        string $optionName,
+        $optionValue,
+        array $translationOptions
+    ) {
+        if (is_array($subject)) {
+            foreach ($subject as $key => $value) {
+                $subject[$key] = $this->translateFinisherOption(
+                    $value,
+                    $formRuntime,
+                    $optionName . '.' . $value,
+                    $value,
+                    $translationOptions
+                );
+            }
+            return $subject;
+        }
 
-        $optionValue = TranslationService::getInstance()->translateFinisherOption(
+        return TranslationService::getInstance()->translateFinisherOption(
             $formRuntime,
             $this->finisherIdentifier,
             $optionName,
             $optionValue,
-            $renderingOptions
+            $translationOptions
         );
+    }
 
-        if (empty($optionValue)) {
-            if ($defaultValue !== null) {
-                $optionValue = $defaultValue;
-            }
+    /**
+     * You can encapsulate a option value with {}.
+     * This enables you to access every getable property from the
+     * TYPO3\CMS\Form\Domain\Runtime\FormRuntime.
+     *
+     * For example: {formState.formValues.<elemenIdentifier>}
+     * or {<elemenIdentifier>}
+     *
+     * Both examples are equal to "$formRuntime->getFormState()->getFormValues()[<elemenIdentifier>]"
+     * There is a special option value '{__currentTimestamp}'.
+     * This will be replaced with the current timestamp.
+     *
+     * @param string|array $needle
+     * @param FormRuntime $formRuntime
+     * @return mixed
+     */
+    protected function substituteRuntimeReferences($needle, FormRuntime $formRuntime)
+    {
+        // neither array nor string, directly return
+        if (!is_array($needle) && !is_string($needle)) {
+            return $needle;
         }
-        return $optionValue;
+
+        // resolve (recursively) all array items
+        if (is_array($needle)) {
+            return array_map(
+                function ($item) use ($formRuntime) {
+                    return $this->substituteRuntimeReferences($item, $formRuntime);
+                },
+                $needle
+            );
+        }
+
+        // substitute one(!) variable in string which either could result
+        // again in a string or an array representing multiple values
+        if (preg_match('/^{([^}]+)}$/', $needle, $matches)) {
+            return $this->resolveRuntimeReference(
+                $matches[1],
+                $formRuntime
+            );
+        }
+
+        // in case string contains more than just one variable or just a static
+        // value that does not need to be substituted at all, candidates are:
+        // * "prefix{variable}suffix
+        // * "{variable-1},{variable-2}"
+        // * "some static value"
+        // * mixed cases of the above
+        return preg_replace_callback(
+            '/{([^}]+)}/',
+            function ($matches) use ($formRuntime) {
+                $value = $this->resolveRuntimeReference(
+                    $matches[1],
+                    $formRuntime
+                );
+
+                // substitute each match by returning the resolved value
+                if (!is_array($value)) {
+                    return $value;
+                }
+
+                // now the resolve value is an array that shall substitute
+                // a variable in a string that probably is not the only one
+                // or is wrapped with other static string content (see above)
+                // ... which is just not possible
+                throw new FinisherException(
+                    'Cannot convert array to string',
+                    1519239265
+                );
+            },
+            $needle
+        );
+    }
+
+    /**
+     * Resolving property by name from submitted form data.
+     *
+     * @param string $property
+     * @param FormRuntime $formRuntime
+     * @return int|string|array
+     */
+    protected function resolveRuntimeReference(string $property, FormRuntime $formRuntime)
+    {
+        if ($property === '__currentTimestamp') {
+            return time();
+        }
+        // try to resolve the path '{...}' within the FormRuntime
+        $value = ObjectAccess::getPropertyPath($formRuntime, $property);
+        if ($value === null) {
+            // try to resolve the path '{...}' within the FinisherVariableProvider
+            $value = ObjectAccess::getPropertyPath(
+                $this->finisherContext->getFinisherVariableProvider(),
+                $property
+            );
+        }
+        if ($value !== null) {
+            return $value;
+        }
+        // in case no value could be resolved
+        return '{' . $property . '}';
     }
 
     /**
index 9436a1a..c7807c3 100644 (file)
@@ -16,12 +16,13 @@ namespace TYPO3\CMS\Form\Tests\Unit\Domain\Finishers;
  */
 
 use Prophecy\Argument;
+use Prophecy\Prophecy\ObjectProphecy;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
-use TYPO3\CMS\Extbase\Object\ObjectManager;
 use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
+use TYPO3\CMS\Form\Domain\Finishers\Exception\FinisherException;
 use TYPO3\CMS\Form\Domain\Finishers\FinisherContext;
+use TYPO3\CMS\Form\Domain\Finishers\FinisherVariableProvider;
 use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
-use TYPO3\CMS\Form\Service\TranslationService;
 use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 
 /**
@@ -103,7 +104,7 @@ class AbstractFinisherTest extends UnitTestCase
     /**
      * @test
      */
-    public function parseOptionReturnsArrayOptionValuesAsArray(): void
+    public function parseOptionReturnsBoolOptionValuesAsBool(): void
     {
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
@@ -113,75 +114,96 @@ class AbstractFinisherTest extends UnitTestCase
         );
 
         $mockAbstractFinisher->_set('options', [
-            'foo' => ['bar', 'foobar']
+            'foo1' => false,
         ]);
 
-        $expected = ['bar', 'foobar'];
+        $expected = false;
 
-        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'foo'));
+        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'foo1'));
     }
 
     /**
      * @test
      */
-    public function parseOptionReturnsBoolOptionValuesAsBool(): void
+    public function parseOptionReturnsDefaultOptionValueIfOptionNameNotExistsWithinOptionsButWithinDefaultOptions(): void
     {
+        $expected = 'defaultValue';
+
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
             '',
-            false
+            false,
+            false,
+            true,
+            [
+                'translateFinisherOption'
+            ]
         );
 
-        $mockAbstractFinisher->_set('options', [
-            'foo1' => false,
+        $mockAbstractFinisher
+            ->expects($this->any())
+            ->method('translateFinisherOption')
+            ->willReturnArgument(0);
+
+        $mockAbstractFinisher->_set('options', []);
+        $mockAbstractFinisher->_set('defaultOptions', [
+            'subject' => $expected
         ]);
 
-        $expected = false;
+        $finisherContextProphecy = $this->prophesize(FinisherContext::class);
 
-        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'foo1'));
+        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
+        $formRuntimeProphecy->offsetExists(Argument::cetera())->willReturn(true);
+        $formRuntimeProphecy->offsetGet(Argument::cetera())->willReturn(null);
+
+        $finisherContextProphecy->getFormRuntime(Argument::cetera())
+            ->willReturn($formRuntimeProphecy->reveal());
+        $finisherContextProphecy->getFinisherVariableProvider(Argument::cetera())
+            ->willReturn(new FinisherVariableProvider);
+
+        $mockAbstractFinisher->_set('finisherContext', $finisherContextProphecy->reveal());
+
+        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'subject'));
     }
 
     /**
      * @test
      */
-    public function parseOptionReturnsValueFromFormRuntimeIfOptionNameReferenceAFormElementIdentifierWhoseValueIsAString(): void
+    public function parseOptionReturnsDefaultOptionValueIfOptionValueIsAFormElementReferenceAndTheFormElementValueIsEmpty(): void
     {
-        $objectManagerProphecy = $this->prophesize(ObjectManager::class);
-        GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManagerProphecy->reveal());
-
-        $mockTranslationService = $this->getAccessibleMock(TranslationService::class, [
-            'translateFinisherOption'
-        ], [], '', false);
-
-        $mockTranslationService
-            ->expects($this->any())
-            ->method('translateFinisherOption')
-            ->willReturnArgument(3);
-
-        $objectManagerProphecy
-            ->get(TranslationService::class)
-            ->willReturn($mockTranslationService);
-
-        $expected = 'element-value';
         $elementIdentifier = 'element-identifier-1';
+        $expected = 'defaultValue';
 
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
             '',
-            false
+            false,
+            false,
+            true,
+            [
+                'translateFinisherOption'
+            ]
         );
 
+        $mockAbstractFinisher
+            ->expects($this->any())
+            ->method('translateFinisherOption')
+            ->willReturnArgument(0);
+
         $mockAbstractFinisher->_set('options', [
             'subject' => '{' . $elementIdentifier . '}'
         ]);
+        $mockAbstractFinisher->_set('defaultOptions', [
+            'subject' => $expected
+        ]);
 
         $finisherContextProphecy = $this->prophesize(FinisherContext::class);
 
-        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
-        $formRuntimeProphecy->offsetExists(Argument::exact($elementIdentifier))->willReturn(true);
-        $formRuntimeProphecy->offsetGet(Argument::exact($elementIdentifier))->willReturn($expected);
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier => ''
+        ]);
 
         $finisherContextProphecy->getFormRuntime(Argument::cetera())
             ->willReturn($formRuntimeProphecy->reveal());
@@ -194,44 +216,41 @@ class AbstractFinisherTest extends UnitTestCase
     /**
      * @test
      */
-    public function parseOptionReturnsNoReplacedValueFromFormRuntimeIfOptionNameReferenceAFormElementIdentifierWhoseValueIsNotAString(): void
+    public function parseOptionResolvesFormElementReferenceFromTranslation(): void
     {
-        $objectManagerProphecy = $this->prophesize(ObjectManager::class);
-        GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManagerProphecy->reveal());
-
-        $mockTranslationService = $this->getAccessibleMock(TranslationService::class, [
-            'translateFinisherOption'
-        ], [], '', false);
-
-        $mockTranslationService
-            ->expects($this->any())
-            ->method('translateFinisherOption')
-            ->willReturnArgument(3);
-
-        $objectManagerProphecy
-            ->get(TranslationService::class)
-            ->willReturn($mockTranslationService);
-
-        $elementIdentifier = 'element-identifier-1';
-        $expected = '{' . $elementIdentifier . '}';
-
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
             '',
-            false
+            false,
+            false,
+            true,
+            [
+                'translateFinisherOption'
+            ]
         );
 
+        $elementIdentifier = 'element-identifier-1';
+        $elementValue = 'element-value-1';
+        $elementReferenceName = '{' . $elementIdentifier . '}';
+
+        $translationValue = 'subject: ' . $elementReferenceName;
+        $expected = 'subject: ' . $elementValue;
+
+        $mockAbstractFinisher
+            ->expects($this->any())
+            ->method('translateFinisherOption')
+            ->willReturn($translationValue);
+
         $mockAbstractFinisher->_set('options', [
-            'subject' => '{' . $elementIdentifier . '}'
+            'subject' => ''
         ]);
 
         $finisherContextProphecy = $this->prophesize(FinisherContext::class);
 
-        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
-        $formRuntimeProphecy->offsetExists(Argument::exact($elementIdentifier))->willReturn(true);
-        $formElementValue = new \DateTime;
-        $formRuntimeProphecy->offsetGet(Argument::exact($elementIdentifier))->willReturn($formElementValue);
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier => $elementValue
+        ]);
 
         $finisherContextProphecy->getFormRuntime(Argument::cetera())
             ->willReturn($formRuntimeProphecy->reveal());
@@ -244,26 +263,28 @@ class AbstractFinisherTest extends UnitTestCase
     /**
      * @test
      */
-    public function parseOptionReturnsNoReplacedValueFromFormRuntimeIfOptionNameReferenceANonExistingFormElement(): void
+    public function substituteRuntimeReferencesReturnsArrayIfInputIsArray(): void
     {
-        $objectManagerProphecy = $this->prophesize(ObjectManager::class);
-        GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManagerProphecy->reveal());
-
-        $mockTranslationService = $this->getAccessibleMock(TranslationService::class, [
-            'translateFinisherOption'
-        ], [], '', false);
+        $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
+            AbstractFinisher::class,
+            [],
+            '',
+            false
+        );
 
-        $mockTranslationService
-            ->expects($this->any())
-            ->method('translateFinisherOption')
-            ->willReturnArgument(3);
+        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
 
-        $objectManagerProphecy
-            ->get(TranslationService::class)
-            ->willReturn($mockTranslationService);
+        $input = ['bar', 'foobar', ['x', 'y']];
+        $expected = ['bar', 'foobar', ['x', 'y']];
 
-        $elementIdentifier = 'element-identifier-1';
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
+    }
 
+    /**
+     * @test
+     */
+    public function substituteRuntimeReferencesReturnsStringIfInputIsString(): void
+    {
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
@@ -271,48 +292,42 @@ class AbstractFinisherTest extends UnitTestCase
             false
         );
 
-        $mockAbstractFinisher->_set('options', [
-            'subject' => '{' . $elementIdentifier . '}'
-        ]);
-
-        $finisherContextProphecy = $this->prophesize(FinisherContext::class);
-
         $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
-        $formRuntimeProphecy->offsetExists(Argument::cetera())->willReturn(true);
-        $formRuntimeProphecy->offsetGet(Argument::cetera())->willReturn(false);
 
-        $finisherContextProphecy->getFormRuntime(Argument::cetera())
-            ->willReturn($formRuntimeProphecy->reveal());
-
-        $mockAbstractFinisher->_set('finisherContext', $finisherContextProphecy->reveal());
+        $input = 'foobar';
+        $expected = 'foobar';
 
-        $expected = '{' . $elementIdentifier . '}';
-        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'subject'));
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
     }
 
     /**
      * @test
      */
-    public function parseOptionReturnsDefaultOptionValueIfOptionNameNotExistsWithinOptionsButWithinDefaultOptions(): void
+    public function substituteRuntimeReferencesReturnsValueFromFormRuntimeIfInputReferenceAFormElementIdentifierWhoseValueIsAString(): void
     {
-        $objectManagerProphecy = $this->prophesize(ObjectManager::class);
-        GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManagerProphecy->reveal());
-
-        $mockTranslationService = $this->getAccessibleMock(TranslationService::class, [
-            'translateFinisherOption'
-        ], [], '', false);
+        $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
+            AbstractFinisher::class,
+            [],
+            '',
+            false
+        );
 
-        $mockTranslationService
-            ->expects($this->any())
-            ->method('translateFinisherOption')
-            ->willReturnArgument(3);
+        $elementIdentifier = 'element-identifier-1';
+        $input = '{' . $elementIdentifier . '}';
+        $expected = 'element-value';
 
-        $objectManagerProphecy
-            ->get(TranslationService::class)
-            ->willReturn($mockTranslationService);
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier => $expected
+        ]);
 
-        $expected = 'defaultValue';
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
+    }
 
+    /**
+     * @test
+     */
+    public function substituteRuntimeReferencesReturnsValueFromFormRuntimeIfInputReferenceMultipleFormElementIdentifierWhoseValueIsAString(): void
+    {
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
@@ -320,49 +335,50 @@ class AbstractFinisherTest extends UnitTestCase
             false
         );
 
-        $mockAbstractFinisher->_set('options', []);
-        $mockAbstractFinisher->_set('defaultOptions', [
-            'subject' => $expected
-        ]);
-
-        $finisherContextProphecy = $this->prophesize(FinisherContext::class);
+        $elementIdentifier1 = 'element-identifier-1';
+        $elementValue1 = 'element-value-1';
+        $elementIdentifier2 = 'element-identifier-2';
+        $elementValue2 = 'element-value-2';
 
-        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
-        $formRuntimeProphecy->offsetExists(Argument::cetera())->willReturn(true);
-        $formRuntimeProphecy->offsetGet(Argument::cetera())->willReturn(false);
+        $input = '{' . $elementIdentifier1 . '},{' . $elementIdentifier2 . '}';
+        $expected = $elementValue1 . ',' . $elementValue2;
 
-        $finisherContextProphecy->getFormRuntime(Argument::cetera())
-            ->willReturn($formRuntimeProphecy->reveal());
-
-        $mockAbstractFinisher->_set('finisherContext', $finisherContextProphecy->reveal());
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier1 => $elementValue1,
+            $elementIdentifier2 => $elementValue2
+        ]);
 
-        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'subject'));
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
     }
 
     /**
      * @test
      */
-    public function parseOptionReturnsDefaultOptionValueIfOptionValueIsAFormElementReferenceAndTheFormElementValueIsEmpty(): void
+    public function substituteRuntimeReferencesReturnsValueFromFormRuntimeIfInputReferenceAFormElementIdentifierWhoseValueIsAnArray(): void
     {
-        $objectManagerProphecy = $this->prophesize(ObjectManager::class);
-        GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManagerProphecy->reveal());
-
-        $mockTranslationService = $this->getAccessibleMock(TranslationService::class, [
-            'translateFinisherOption'
-        ], [], '', false);
+        $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
+            AbstractFinisher::class,
+            [],
+            '',
+            false
+        );
 
-        $mockTranslationService
-            ->expects($this->any())
-            ->method('translateFinisherOption')
-            ->willReturnArgument(3);
+        $elementIdentifier = 'element-identifier-1';
+        $input = '{' . $elementIdentifier . '}';
+        $expected = ['bar', 'foobar'];
 
-        $objectManagerProphecy
-            ->get(TranslationService::class)
-            ->willReturn($mockTranslationService);
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier => $expected
+        ]);
 
-        $elementIdentifier = 'element-identifier-1';
-        $expected = 'defaultValue';
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
+    }
 
+    /**
+     * @test
+     */
+    public function substituteRuntimeReferencesReturnsValueFromFormRuntimeIfInputIsArrayAndSomeItemsReferenceAFormElementIdentifierWhoseValueIsAnArray(): void
+    {
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
@@ -370,48 +386,90 @@ class AbstractFinisherTest extends UnitTestCase
             false
         );
 
-        $mockAbstractFinisher->_set('options', [
-            'subject' => '{' . $elementIdentifier . '}'
-        ]);
-        $mockAbstractFinisher->_set('defaultOptions', [
-            'subject' => $expected
+        $elementIdentifier1 = 'element-identifier-1';
+        $elementValue1 = ['klaus', 'fritz'];
+        $elementIdentifier2 = 'element-identifier-2';
+        $elementValue2 = ['stan', 'steve'];
+
+        $input = [
+            '{' . $elementIdentifier1 . '}',
+            'static value',
+            'norbert' => [
+                'lisa',
+                '{' . $elementIdentifier1 . '}',
+                '{' . $elementIdentifier2 . '}',
+            ],
+        ];
+        $expected = [
+            ['klaus', 'fritz'],
+            'static value',
+            'norbert' => [
+                'lisa',
+                ['klaus', 'fritz'],
+                ['stan', 'steve'],
+            ]
+        ];
+
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier1 => $elementValue1,
+            $elementIdentifier2 => $elementValue2
         ]);
 
-        $finisherContextProphecy = $this->prophesize(FinisherContext::class);
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
+    }
 
-        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
-        $formRuntimeProphecy->offsetExists(Argument::exact($elementIdentifier))->willReturn(true);
-        $formRuntimeProphecy->offsetGet(Argument::exact($elementIdentifier))->willReturn('');
+    /**
+     * @test
+     */
+    public function substituteRuntimeReferencesReturnsNoReplacedValueIfInputReferenceANonExistingFormElement(): void
+    {
+        $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
+            AbstractFinisher::class,
+            [],
+            '',
+            false
+        );
 
-        $finisherContextProphecy->getFormRuntime(Argument::cetera())
-            ->willReturn($formRuntimeProphecy->reveal());
+        $elementIdentifier = 'element-identifier-1';
+        $input = '{' . $elementIdentifier . '}';
+        $expected = '{' . $elementIdentifier . '}';
 
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier => $expected
+        ]);
+
+        $finisherContextProphecy = $this->prophesize(FinisherContext::class);
+        $finisherContextProphecy->getFinisherVariableProvider(Argument::cetera())->willReturn(new FinisherVariableProvider);
         $mockAbstractFinisher->_set('finisherContext', $finisherContextProphecy->reveal());
 
-        $this->assertSame($expected, $mockAbstractFinisher->_call('parseOption', 'subject'));
+        $this->assertSame($expected, $mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal()));
     }
 
     /**
      * @test
      */
-    public function parseOptionReturnsTimestampIfOptionValueIsATimestampRequestTrigger(): void
+    public function substituteRuntimeReferencesReturnsTimestampIfInputIsATimestampRequestTrigger(): void
     {
-        $objectManagerProphecy = $this->prophesize(ObjectManager::class);
-        GeneralUtility::setSingletonInstance(ObjectManager::class, $objectManagerProphecy->reveal());
+        $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
+            AbstractFinisher::class,
+            [],
+            '',
+            false
+        );
 
-        $mockTranslationService = $this->getAccessibleMock(TranslationService::class, [
-            'translateFinisherOption'
-        ], [], '', false);
+        $input = '{__currentTimestamp}';
+        $expected = '#^([0-9]{10})$#';
 
-        $mockTranslationService
-            ->expects($this->any())
-            ->method('translateFinisherOption')
-            ->willReturnArgument(3);
+        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
 
-        $objectManagerProphecy
-            ->get(TranslationService::class)
-            ->willReturn($mockTranslationService);
+        $this->assertEquals(1, preg_match($expected, (string)$mockAbstractFinisher->_call('substituteRuntimeReferences', $input, $formRuntimeProphecy->reveal())));
+    }
 
+    /**
+     * @test
+     */
+    public function substituteRuntimeReferencesThrowsExceptionOnMultipleVariablesResolvedAsArray(): void
+    {
         $mockAbstractFinisher = $this->getAccessibleMockForAbstractClass(
             AbstractFinisher::class,
             [],
@@ -419,20 +477,39 @@ class AbstractFinisherTest extends UnitTestCase
             false
         );
 
-        $mockAbstractFinisher->_set('options', [
-            'crdate' => '{__currentTimestamp}'
+        $elementIdentifier = 'element-identifier-1';
+        $input = 'BEFORE {' . $elementIdentifier . '} AFTER';
+
+        $formRuntimeProphecy = $this->createFormRuntimeProphecy([
+            $elementIdentifier => ['value-1', 'value-2']
         ]);
 
         $finisherContextProphecy = $this->prophesize(FinisherContext::class);
+        $finisherContextProphecy->getFinisherVariableProvider(Argument::cetera())->willReturn(new FinisherVariableProvider);
+        $mockAbstractFinisher->_set('finisherContext', $finisherContextProphecy->reveal());
 
-        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
-
-        $finisherContextProphecy->getFormRuntime(Argument::cetera())
-            ->willReturn($formRuntimeProphecy->reveal());
+        $this->expectException(FinisherException::class);
+        $this->expectExceptionCode(1519239265);
 
-        $mockAbstractFinisher->_set('finisherContext', $finisherContextProphecy->reveal());
+        $mockAbstractFinisher->_call(
+            'substituteRuntimeReferences',
+            $input,
+            $formRuntimeProphecy->reveal()
+        );
+    }
 
-        $expected = '#^([0-9]{10})$#';
-        $this->assertEquals(1, preg_match($expected, $mockAbstractFinisher->_call('parseOption', 'crdate')));
+    /**
+     * @param array $values Key/Value pairs to be retrievable
+     * @return ObjectProphecy|FormRuntime
+     */
+    protected function createFormRuntimeProphecy(array $values)
+    {
+        /** @var ObjectProphecy|FormRuntime $formRuntimeProphecy */
+        $formRuntimeProphecy = $this->prophesize(FormRuntime::class);
+        foreach ($values as $key => $value) {
+            $formRuntimeProphecy->offsetExists(Argument::exact($key))->willReturn(true);
+            $formRuntimeProphecy->offsetGet(Argument::exact($key))->willReturn($value);
+        }
+        return $formRuntimeProphecy;
     }
 }