├── Resources
├── Private
│ ├── Fusion
│ │ ├── Root.fusion
│ │ ├── Component
│ │ │ ├── Molecule
│ │ │ │ ├── TabNavigation.fusion
│ │ │ │ ├── Tab.fusion
│ │ │ │ └── TabPane.fusion
│ │ │ ├── Organism
│ │ │ │ └── Tabs.fusion
│ │ │ └── ABTestingContainer
│ │ │ │ ├── TestingCaseRenderer.fusion
│ │ │ │ └── BackendView.fusion
│ │ └── NodeTypes
│ │ │ └── ABTestingContainer.fusion
│ ├── Templates
│ │ └── Module
│ │ │ └── AbTesting
│ │ │ ├── FeatureModule
│ │ │ ├── Index.html
│ │ │ ├── ShowPages.html
│ │ │ ├── DeleteDecision.html
│ │ │ ├── EditDecision.html
│ │ │ ├── AddDecisionToFeature.html
│ │ │ ├── DeleteFeature.html
│ │ │ ├── NewFeature.html
│ │ │ ├── ShowFeature.html
│ │ │ ├── EditFeature.html
│ │ │ ├── ChooseDecider.html
│ │ │ └── ListFeatures.html
│ │ │ └── Index.html
│ ├── Partials
│ │ ├── DeciderInputSections
│ │ │ ├── PercentageDecider.html
│ │ │ └── PercentageAbcDecider.html
│ │ ├── DecisionInputSection.html
│ │ ├── DecisionInput.html
│ │ ├── Module
│ │ │ └── FeatureModule
│ │ │ │ ├── PageList.html
│ │ │ │ ├── DeciderSection.html
│ │ │ │ └── DecisionList.html
│ │ ├── PageList.html
│ │ └── DeciderList.html
│ └── Script
│ │ └── AbTesting.js
└── Public
│ └── JavaScript
│ └── AbTesting.js
├── Documentation
├── feature-list.jpg
├── ab-testing-container.jpg
└── ab-testing-frontend.jpg
├── Configuration
├── Objects.yaml
├── NodeTypes.ABTestingContainer.yaml
├── NodeTypes.Content.NodeReference.yaml
├── NodeTypes.Mixins.yaml
├── Policy.yaml
├── Views.yaml
└── Settings.yaml
├── Classes
├── Domain
│ ├── Comparator
│ │ ├── ComparatorInterface.php
│ │ ├── FixedValueComparator.php
│ │ ├── RandomComparator.php
│ │ ├── HexToIntDowncast.php
│ │ └── UserIdentifierComparator.php
│ ├── Repository
│ │ ├── DecisionRepository.php
│ │ └── FeatureRepository.php
│ ├── Decider
│ │ ├── PercentageAbcDecider.php
│ │ ├── DeciderInterface.php
│ │ └── PercentageDecider.php
│ ├── Session
│ │ └── ABTestingSession.php
│ ├── Dto
│ │ └── DeciderObject.php
│ ├── Model
│ │ ├── Feature.php
│ │ └── Decision.php
│ ├── Factory
│ │ └── DeciderFactory.php
│ ├── DataSource
│ │ ├── Tests.php
│ │ └── Features.php
│ ├── Service
│ │ ├── FeatureService.php
│ │ └── DecisionService.php
│ └── Http
│ │ └── Middleware
│ │ └── AbTestingCookieMiddleware.php
├── Controller
│ └── Module
│ │ ├── AbTestingController.php
│ │ └── AbTesting
│ │ └── FeatureModuleController.php
├── ViewHelpers
│ └── ClassNameViewHelper.php
└── Eel
│ ├── FeatureHelper.php
│ └── DecisionHelper.php
├── composer.json
├── LICENSE
├── Migrations
├── Mysql
│ ├── Version20211125113158.php
│ ├── Version20190902152414.php
│ ├── Version20190902140817.php
│ ├── Version20190830132402.php
│ └── Version20180703153541.php
└── Postgresql
│ └── Version20230301144616.php
├── README.md
└── Tests
└── Unit
└── Domain
└── Decider
├── AbstractPercentageDeciderTest.php
└── PercentageDeciderTest.php
/Resources/Private/Fusion/Root.fusion:
--------------------------------------------------------------------------------
1 | include: '**/*'
2 |
--------------------------------------------------------------------------------
/Documentation/feature-list.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wysiwyg-software-design/Wysiwyg.ABTesting/HEAD/Documentation/feature-list.jpg
--------------------------------------------------------------------------------
/Configuration/Objects.yaml:
--------------------------------------------------------------------------------
1 | Wysiwyg\ABTesting\Domain\Decider\PercentageDecider:
2 | properties:
3 | deciderName:
4 | value: 'Percentage'
5 |
--------------------------------------------------------------------------------
/Documentation/ab-testing-container.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wysiwyg-software-design/Wysiwyg.ABTesting/HEAD/Documentation/ab-testing-container.jpg
--------------------------------------------------------------------------------
/Documentation/ab-testing-frontend.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wysiwyg-software-design/Wysiwyg.ABTesting/HEAD/Documentation/ab-testing-frontend.jpg
--------------------------------------------------------------------------------
/Classes/Domain/Comparator/ComparatorInterface.php:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/Index.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/Classes/Controller/Module/AbTestingController.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/Resources/Private/Fusion/Component/Molecule/TabNavigation.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.AbTesting:Component.Molecule.TabNavigation) < prototype(Neos.Fusion:Component) {
2 | /**
3 | PROPS:
4 | - tabs
5 | */
6 | tabs = ''
7 |
8 | renderer = afx`
9 |
12 | `
13 | }
14 |
--------------------------------------------------------------------------------
/Resources/Private/Fusion/Component/Molecule/Tab.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.AbTesting:Component.Molecule.Tab) < prototype(Neos.Fusion:Component) {
2 | target = ''
3 | text = ''
4 | active = false
5 |
6 | renderer = afx`
7 |
8 | {props.text}
9 |
10 | `
11 | }
12 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/DecisionInputSection.html:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/Classes/Domain/Decider/PercentageAbcDecider.php:
--------------------------------------------------------------------------------
1 | 'a', 'b' => 'b', 'c' => 'c'];
8 |
9 | public function getTitle()
10 | {
11 | return 'PercentageAbcDecider';
12 | }
13 |
14 | public function __toString()
15 | {
16 | return PercentageAbcDecider::class;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Configuration/NodeTypes.ABTestingContainer.yaml:
--------------------------------------------------------------------------------
1 | 'Wysiwyg.ABTesting:ABTestingContainer':
2 | superTypes:
3 | 'Neos.Neos:Content': true
4 | 'Wysiwyg.ABTesting:ABTestingMixin': true
5 | ui:
6 | label: 'A/B Testing Container'
7 | inlineEditable: true
8 | childNodes:
9 | itemsa:
10 | type: 'Neos.Neos:ContentCollection'
11 | itemsb:
12 | type: 'Neos.Neos:ContentCollection'
13 | itemsc:
14 | type: 'Neos.Neos:ContentCollection'
15 |
--------------------------------------------------------------------------------
/Resources/Private/Fusion/Component/Organism/Tabs.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.AbTesting:Component.Organism.Tabs) < prototype(Neos.Fusion:Component) {
2 | /**
3 | PROPS:
4 | - tabs
5 | */
6 | tabNavigation = ''
7 | tabContent = ''
8 |
9 | renderer = afx`
10 |
11 | {props.tabNavigation}
12 |
13 | {props.tabContent}
14 |
15 |
16 | `
17 | }
18 |
--------------------------------------------------------------------------------
/Classes/Domain/Decider/DeciderInterface.php:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
15 |
16 |
--------------------------------------------------------------------------------
/Resources/Private/Fusion/Component/Molecule/TabPane.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.AbTesting:Component.Molecule.TabPane) < prototype(Neos.Fusion:Component) {
2 | /**
3 | PROPS:
4 | - version
5 | - content
6 | - active
7 | */
8 | id = ''
9 | content = ''
10 | active = false
11 |
12 | renderer = afx`
13 |
14 | {props.content}
15 |
16 | `
17 | }
18 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/DeciderInputSections/PercentageAbcDecider.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wy/neos-abtesting",
3 | "description": "A/B Testing for Neos",
4 | "license": "MIT",
5 | "type": "neos-plugin",
6 | "require": {
7 | "php": "^8.1",
8 | "neos/neos": "^8.0"
9 | },
10 | "autoload": {
11 | "psr-4": {
12 | "Wysiwyg\\ABTesting\\": "Classes/"
13 | }
14 | },
15 | "archive": {
16 | "exclude": ["Documentation/"]
17 | },
18 | "extra": {
19 | "neos": {
20 | "package-key": "Wysiwyg.ABTesting"
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Classes/Domain/Comparator/FixedValueComparator.php:
--------------------------------------------------------------------------------
1 | comparisonValue = $comparisonValue;
17 | }
18 |
19 | public function getComparisonValue(): int
20 | {
21 | return $this->comparisonValue;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Classes/ViewHelpers/ClassNameViewHelper.php:
--------------------------------------------------------------------------------
1 | createQuery();
20 |
21 | $flowQuery->matching($flowQuery->equals('active', 1));
22 |
23 | return $flowQuery->execute()->toArray();
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Configuration/NodeTypes.Mixins.yaml:
--------------------------------------------------------------------------------
1 | 'Wysiwyg.ABTesting:ABTestingMixin':
2 | abstract: TRUE
3 | superTypes:
4 | 'Wysiwyg.ABTesting:Mixins.FeatureProperty': true
5 | ui:
6 | inspector:
7 | groups:
8 | abTesting:
9 | label: 'A / B Testing'
10 | icon: 'icon-eye'
11 |
12 | 'Wysiwyg.ABTesting:Mixins.FeatureProperty':
13 | abstract: TRUE
14 | properties:
15 | abTest:
16 | type: string
17 | defaultValue: ''
18 | ui:
19 | label: 'A/B Test Feature'
20 | inspector:
21 | editor: 'Neos.Neos/Inspector/Editors/SelectBoxEditor'
22 | position: 10
23 | group: abTesting
24 | editorOptions:
25 | dataSourceIdentifier: 'wysiwyg-abtesting-tests'
26 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/DeleteDecision.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Are you sure, you want to delete the decision?
10 |
11 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/Classes/Domain/Comparator/RandomComparator.php:
--------------------------------------------------------------------------------
1 | comparisonValue !== null) {
20 | return $this->comparisonValue;
21 | }
22 |
23 | $randomBytes = random_bytes(3);
24 | $hexadecimalBytes = bin2hex($randomBytes);
25 |
26 | $this->comparisonValue = HexToIntDowncast::sixDigitHexToPercentageInteger($hexadecimalBytes);
27 | return $this->comparisonValue;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Classes/Domain/Comparator/HexToIntDowncast.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/Resources/Private/Fusion/NodeTypes/ABTestingContainer.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.ABTesting:ABTestingContainer) < prototype(Neos.Neos:ContentComponent) {
2 | testingDecision = ${Wysiwyg.ABTesting.Decisions.getDecisionForFeatureByIdentifier(String.toString(q(node).property('abTest')), request.arguments.forceABVersion)}
3 | abTestFeature = ${Wysiwyg.ABTesting.Features.getFeatureById(q(node).property('abTest'))}
4 |
5 | renderer = afx`
6 |
7 |
8 |
9 | `
10 |
11 | @cache {
12 | mode = 'uncached'
13 |
14 | context {
15 | 1 = 'node'
16 | 2 = 'documentNode'
17 | 3 = 'site'
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Resources/Public/JavaScript/AbTesting.js:
--------------------------------------------------------------------------------
1 | (()=>{var i={abTestingCookieString:"",cookieName:document.body.dataset.abTestingCookieName||"WYSIWYG_AB_TESTING",abTestingObject:{},init:function(){this.abTestingCookieString=this.getCookie(),this.abTestingObject=JSON.parse(this.abTestingCookieString||"{}")},getCookie:function(){let t=document.cookie.match(new RegExp("(^| )"+this.cookieName+"=([^;]+)"));if(t)return decodeURIComponent(t[2])},getTrackingStringsArrayForAllFeatures:function(){let t=[];for(let e in this.abTestingObject)t.push(this.getTrackingStringForFeature(e));return t},getTrackingStringForFeature:function(t){return t+"_"+this.getDecisionForFeature(t)},getDecisionsForAllFeatures:function(){return this.abTestingObject},getDecisionForFeature:function(t){if(t in this.abTestingObject)return this.abTestingObject[t]}};window.WY=window.WY||{};window.WY.AbTesting=i;window.addEventListener("load",function(){window.WY.AbTesting.init()});})();
2 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/EditDecision.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 | Edit {decision.decider.title} conditions
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Configuration/Policy.yaml:
--------------------------------------------------------------------------------
1 | privilegeTargets:
2 | 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege':
3 | 'Wysiwyg.ABTesting:Module.AbTesting':
4 | matcher: 'method(Wysiwyg\ABTesting\Controller\Module\AbTestingController->(?(?
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/Module/FeatureModule/PageList.html:
--------------------------------------------------------------------------------
1 |
29 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/PageList.html:
--------------------------------------------------------------------------------
1 |
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 wysiwyg software design GmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Resources/Private/Fusion/Component/ABTestingContainer/TestingCaseRenderer.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.ABTesting:Component.ABTestingContainer.TestingCaseRenderer) < prototype(Neos.Fusion:Component) {
2 | testingDecision = ''
3 |
4 | renderer = Neos.Fusion:Case {
5 | backendView {
6 | condition = ${node.context.inBackend == true}
7 | renderer = Wysiwyg.ABTesting:Component.ABTestingContainer.BackendView {
8 | nodeIdentifier = ${node.identifier}
9 | }
10 | }
11 |
12 | caseB {
13 | condition = ${props.testingDecision == 'b'}
14 | renderer = Neos.Neos:ContentCollection {
15 | nodePath = 'itemsb'
16 | }
17 | }
18 |
19 | caseC {
20 | condition = ${props.testingDecision == 'c'}
21 | renderer = Neos.Neos:ContentCollection {
22 | nodePath = 'itemsc'
23 | }
24 | }
25 |
26 | default {
27 | condition = ${true}
28 | renderer = Neos.Neos:ContentCollection {
29 | nodePath = 'itemsa'
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Configuration/Views.yaml:
--------------------------------------------------------------------------------
1 | - requestFilter: 'parentRequest.isPackage("Neos.Neos") && isPackage("Wysiwyg.ABTesting")'
2 | options:
3 | layoutRootPaths:
4 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Layouts'
5 | 'Neos.Media': 'resource://Neos.Media/Private/Layouts'
6 | 'Neos.Neos': 'resource://Neos.Neos/Private/Layouts'
7 | partialRootPaths:
8 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Partials'
9 | 'Neos.Media': 'resource://Neos.Media/Private/Partials'
10 | 'Neos.Neos': 'resource://Neos.Neos/Private/Partials'
11 |
12 | - requestFilter: 'parentRequest.isPackage("Neos.Neos") && isPackage("Wysiwyg.ABTesting") && (isController("Module\AbTesting\FeatureModule"))'
13 | options:
14 | layoutRootPaths:
15 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Layouts'
16 | 'Neos.Media': 'resource://Neos.Media/Private/Layouts'
17 | 'Neos.Neos': 'resource://Neos.Neos/Private/Layouts'
18 | partialRootPaths:
19 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Partials'
20 | 'Neos.Media': 'resource://Neos.Media/Private/Partials'
21 | 'Neos.Neos': 'resource://Neos.Neos/Private/Partials'
22 |
--------------------------------------------------------------------------------
/Classes/Eel/FeatureHelper.php:
--------------------------------------------------------------------------------
1 | featureRepository->findByIdentifier($featureId);
29 | }
30 |
31 | return null;
32 | }
33 |
34 | /**
35 | * @param string $methodName
36 | * @return boolean
37 | */
38 | public function allowsCallOfMethod($methodName)
39 | {
40 | switch ($methodName) {
41 | case 'getFeatureById':
42 | return true;
43 | }
44 |
45 | return false;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Classes/Domain/Session/ABTestingSession.php:
--------------------------------------------------------------------------------
1 | decisions[] = $decision;
26 | }
27 |
28 | /**
29 | * @return array
30 | */
31 | public function getItems()
32 | {
33 | return $this->decisions;
34 | }
35 |
36 | /**
37 | * @Flow\Session(autoStart = true)
38 | * @param $feature
39 | *
40 | * @return string | null
41 | */
42 | public function getDecisionForFeature($feature)
43 | {
44 | if (array_key_exists($feature, $this->decisions)) {
45 | return $this->decisions[$feature];
46 | }
47 |
48 | return null;
49 | }
50 |
51 | /**
52 | * @Flow\Session(autoStart = true)
53 | *
54 | * @param $feature
55 | * @param $decision
56 | */
57 | public function setDecisionForFeature($feature, $decision)
58 | {
59 | $this->decisions[$feature] = $decision;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Migrations/Mysql/Version20211125113158.php:
--------------------------------------------------------------------------------
1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
26 |
27 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD defaultdecision VARCHAR(255) NOT NULL');
28 | }
29 |
30 | /**
31 | * @param Schema $schema
32 | *
33 | * @return void
34 | * @throws \Doctrine\DBAL\Exception
35 | */
36 | public function down(Schema $schema): void
37 | {
38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
39 |
40 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP defaultdecision');
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Migrations/Mysql/Version20190902152414.php:
--------------------------------------------------------------------------------
1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
26 |
27 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature CHANGE defaultdecision defaultdecision VARCHAR(255) DEFAULT NULL');
28 | }
29 |
30 | /**
31 | * @param Schema $schema
32 | *
33 | * @return void
34 | * @throws \Doctrine\DBAL\Exception
35 | */
36 | public function down(Schema $schema): void
37 | {
38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
39 |
40 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature CHANGE defaultdecision defaultdecision VARCHAR(255) NOT NULL');
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Migrations/Mysql/Version20190902140817.php:
--------------------------------------------------------------------------------
1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
27 |
28 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP priority, CHANGE decider deciderclassname VARCHAR(255) NOT NULL');
29 | }
30 |
31 | /**
32 | * @param Schema $schema
33 | *
34 | * @return void
35 | * @throws \Doctrine\DBAL\Exception
36 | */
37 | public function down(Schema $schema): void
38 | {
39 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
40 |
41 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD priority INT NOT NULL, CHANGE deciderclassname decider VARCHAR(255) NOT NULL');
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Classes/Domain/Dto/DeciderObject.php:
--------------------------------------------------------------------------------
1 | deciderName;
30 | }
31 |
32 | /**
33 | * @param string $deciderName
34 | */
35 | public function setDeciderName($deciderName)
36 | {
37 | $this->deciderName = $deciderName;
38 | }
39 |
40 | /**
41 | * @return string
42 | */
43 | public function getDeciderClass()
44 | {
45 | return $this->deciderClass;
46 | }
47 |
48 | /**
49 | * @param string $deciderClass
50 | */
51 | public function setDeciderClass($deciderClass)
52 | {
53 | $this->deciderClass = $deciderClass;
54 | }
55 |
56 | /**
57 | * @return Feature
58 | */
59 | public function getFeature(): Feature
60 | {
61 | return $this->feature;
62 | }
63 |
64 | /**
65 | * @param Feature $feature
66 | */
67 | public function setFeature(Feature $feature): void
68 | {
69 | $this->feature = $feature;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/DeleteFeature.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Are you sure, you want to delete the A/B Test?
10 |
11 |
12 |
16 |
17 |
18 |
19 |
20 |
21 | A/B Test can't be deleted.
22 |
23 |
24 | Please remove the A/B Testing Feature on all pages in order to delete this A/B Test.
25 |
26 |
27 |
28 |
29 |
30 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/Classes/Domain/Model/Feature.php:
--------------------------------------------------------------------------------
1 |
27 | */
28 | protected $decisions;
29 |
30 | /**
31 | * @return string
32 | */
33 | public function getFeatureName()
34 | {
35 | return $this->featureName;
36 | }
37 |
38 | /**
39 | * @param string $featureName
40 | */
41 | public function setFeatureName($featureName)
42 | {
43 | $this->featureName = $featureName;
44 | }
45 |
46 | /**
47 | * @return bool
48 | */
49 | public function isActive()
50 | {
51 | return $this->active;
52 | }
53 |
54 | /**
55 | * @param bool $active
56 | */
57 | public function setActive($active)
58 | {
59 | $this->active = $active;
60 | }
61 |
62 | /**
63 | * @return ArrayCollection
64 | */
65 | public function getDecisions()
66 | {
67 | return $this->decisions;
68 | }
69 |
70 | /**
71 | * @param ArrayCollection $decisions
72 | */
73 | public function setDecisions($decisions)
74 | {
75 | $this->decisions = $decisions;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Migrations/Mysql/Version20190830132402.php:
--------------------------------------------------------------------------------
1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
26 |
27 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP defaultdecision');
28 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature ADD defaultdecision VARCHAR(255) NOT NULL');
29 | }
30 |
31 | /**
32 | * @param Schema $schema
33 | *
34 | * @return void
35 | * @throws \Doctrine\DBAL\Exception
36 | */
37 | public function down(Schema $schema): void
38 | {
39 | // this down() migration is autogenerated, please modify it to your needs
40 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
41 |
42 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD defaultdecision VARCHAR(255) NOT NULL');
43 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature DROP defaultdecision');
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/Module/FeatureModule/DeciderSection.html:
--------------------------------------------------------------------------------
1 |
32 |
--------------------------------------------------------------------------------
/Classes/Domain/Decider/PercentageDecider.php:
--------------------------------------------------------------------------------
1 | 'a', 'b' => 'b'];
10 |
11 | /**
12 | * Returns a decision from a given array.
13 | * Decides by weight given from array values.
14 | *
15 | * Weighted Decision: a(60%) b (40%)
16 | * [
17 | * 'a' => 60,
18 | * 'b' => 40
19 | * ]
20 | *
21 | * Also supports multiple decisions, for example c will be added:
22 | * Weighted Decision: a(40%) b (40%) c (20%)
23 | *
24 | * [
25 | * 'a' => 40,
26 | * 'b' => 40,
27 | * 'c' => 20
28 | * ]
29 | *
30 | * @param array $decisions
31 | * @param ComparatorInterface $comparator
32 | * @return null|string
33 | */
34 | public function decide(array $decisions, ComparatorInterface $comparator)
35 | {
36 | $comparisonValue = $comparator->getComparisonValue();
37 |
38 | $tempDecision = 0;
39 |
40 | foreach ($decisions as $key => $decision) {
41 | $tempDecision += $decision;
42 | if ($tempDecision >= $comparisonValue) {
43 | return $key;
44 | }
45 | }
46 |
47 | return false;
48 | }
49 |
50 | /**
51 | * @return array
52 | */
53 | public function getPossibleDecisions()
54 | {
55 | return $this->possibleDecisions;
56 | }
57 |
58 | public function getTitle()
59 | {
60 | return 'PercentageDecider';
61 | }
62 |
63 | public function __toString()
64 | {
65 | return PercentageDecider::class;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Classes/Domain/Factory/DeciderFactory.php:
--------------------------------------------------------------------------------
1 | deciderSettings, function ($element) {
27 | return (array_key_exists('enabled', $element) && $element['enabled']);
28 | });
29 |
30 | foreach ($enabledDeciders as $enabledDecider => $enabledValue) {
31 | if (class_exists('Wysiwyg\ABTesting\Domain\Decider\\' . $enabledDecider)) {
32 | $this->testDecider[] = 'Wysiwyg\ABTesting\Domain\Decider\\' . $enabledDecider;
33 | }
34 | }
35 | }
36 |
37 | /**
38 | * Gets an Instance of a Decider by Class name - since every Decider implements an
39 | * DeciderInterface, return type was set to DeciderInterface
40 | *
41 | * @param $className
42 | * @return DeciderInterface
43 | */
44 | public function getTestDecider($className)
45 | {
46 | foreach ($this->testDecider as $decider) {
47 | if ($className === $decider) {
48 | return new $decider;
49 | }
50 | }
51 |
52 | return null;
53 | }
54 |
55 | public function getAllDecider()
56 | {
57 | return $this->testDecider;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/NewFeature.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 | Create new A/B Test
6 |
7 |
8 |
9 |
10 |
11 |
32 |
33 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/ShowFeature.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/EditFeature.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 | {namespace wysiwyg=Wysiwyg\ABTesting\ViewHelpers}
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
43 |
44 |
--------------------------------------------------------------------------------
/Configuration/Settings.yaml:
--------------------------------------------------------------------------------
1 | Neos:
2 | Fusion:
3 | defaultContext:
4 | 'Wysiwyg.ABTesting.Decisions': 'Wysiwyg\ABTesting\Eel\DecisionHelper'
5 | 'Wysiwyg.ABTesting.Features': 'Wysiwyg\ABTesting\Eel\FeatureHelper'
6 | Neos:
7 | userInterface:
8 | translation:
9 | autoInclude:
10 | Wysiwyg.ABTesting:
11 | - Main
12 |
13 | fusion:
14 | autoInclude:
15 | 'Wysiwyg.ABTesting': TRUE
16 |
17 | modules:
18 | AbTesting:
19 | label: 'A/B Testing'
20 | description: 'A/B Testing Dashboard'
21 | controller: Wysiwyg\ABTesting\Controller\Module\AbTestingController
22 | icon: 'fas fa-briefcase'
23 | submodules:
24 | features:
25 | icon: 'fas fa-cog'
26 | label: 'A/B Tests'
27 | description: 'Configuration of A/B Tests'
28 | controller: Wysiwyg\ABTesting\Controller\Module\AbTesting\FeatureModuleController
29 | widgetTemplatePathAndFileName: 'resource://Neos.Neos/Private/Templates/Module/Widget.html'
30 | actions:
31 | newFeature:
32 | label: 'Create Feature'
33 | title: 'Create Feature'
34 | listFeatures:
35 | label: 'Feature List'
36 | title: 'Feature List'
37 | Flow:
38 | http:
39 | middlewares:
40 | abTestingCookieMiddleware:
41 | middleware: Wysiwyg\ABTesting\Domain\Http\Middleware\AbTestingCookieMiddleware
42 | position: 'after redirect'
43 |
44 | Wysiwyg:
45 | ABTesting:
46 | cookie:
47 | name: 'WYSIWYG_AB_TESTING'
48 | lifetime: '+2 years'
49 | deciders:
50 | PercentageDecider:
51 | enabled: true
52 | PercentageAbcDecider:
53 | enabled: true
54 | # configure the comparator you want to use
55 | # the \Wysiwyg\ABTesting\Domain\Comparator\FixedValueComparator is just for testing, it won't work here
56 | comparatorClassName: Wysiwyg\ABTesting\Domain\Comparator\UserIdentifierComparator
57 |
--------------------------------------------------------------------------------
/Resources/Private/Fusion/Component/ABTestingContainer/BackendView.fusion:
--------------------------------------------------------------------------------
1 | prototype(Wysiwyg.ABTesting:Component.ABTestingContainer.BackendView) < prototype(Neos.Fusion:Component) {
2 | nodeIdentifier = ''
3 |
4 | renderer = Wysiwyg.AbTesting:Component.Organism.Tabs {
5 | tabNavigation = Wysiwyg.AbTesting:Component.Molecule.TabNavigation {
6 | tabs = Neos.Fusion:Array {
7 | tabA = Wysiwyg.AbTesting:Component.Molecule.Tab {
8 | target = ${'#ab-container-a-' + props.nodeIdentifier}
9 | text = 'items A'
10 | active = true
11 | }
12 |
13 | tabB = Wysiwyg.AbTesting:Component.Molecule.Tab {
14 | target = ${'#ab-container-b-' + props.nodeIdentifier}
15 | text = 'items B'
16 | }
17 |
18 | tabC = Wysiwyg.AbTesting:Component.Molecule.Tab {
19 | target = ${'#ab-container-c-' + props.nodeIdentifier}
20 | text = 'items C'
21 | }
22 | }
23 | }
24 |
25 | tabContent = Neos.Fusion:Array {
26 | tabPaneA = Wysiwyg.AbTesting:Component.Molecule.TabPane {
27 | content = Neos.Neos:ContentCollection {
28 | nodePath = 'itemsa'
29 | }
30 | id = ${'ab-container-a-' + props.nodeIdentifier}
31 | active = true
32 | }
33 |
34 | tabPaneB = Wysiwyg.AbTesting:Component.Molecule.TabPane {
35 | content = Neos.Neos:ContentCollection {
36 | nodePath = 'itemsb'
37 | }
38 | id = ${'ab-container-b-' + props.nodeIdentifier}
39 | }
40 |
41 | tabPaneC = Wysiwyg.AbTesting:Component.Molecule.TabPane {
42 | content = Neos.Neos:ContentCollection {
43 | nodePath = 'itemsc'
44 | }
45 | id = ${'ab-container-c-' + props.nodeIdentifier}
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Classes/Domain/DataSource/Tests.php:
--------------------------------------------------------------------------------
1 | getMappedFeaturesToSelectOptions();
46 | }
47 |
48 | protected function getMappedFeaturesToSelectOptions()
49 | {
50 | $allFeatures = $this->featureRepository->findAll();
51 |
52 | $mappedFeatures = [];
53 |
54 | /**
55 | * @var Feature $feature
56 | */
57 | foreach ($allFeatures as $feature) {
58 | $mappedFeatures[] = [
59 | 'label' => $feature->getFeatureName(),
60 | 'value' => $this->persistenceManager->getIdentifierByObject($feature),
61 | 'icon' => 'icon-cog'
62 | ];
63 | }
64 |
65 | return $mappedFeatures;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/ChooseDecider.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 | No Decider to choose.
33 | You can not add any decision. You may have set all possible deciders.
34 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/Classes/Domain/DataSource/Features.php:
--------------------------------------------------------------------------------
1 | getMappedFeaturesToSelectOptions();
46 | }
47 |
48 | /**
49 | * @return array
50 | */
51 | protected function getMappedFeaturesToSelectOptions()
52 | {
53 | $allFeatures = $this->featureRepository->findAll();
54 | $mappedFeatures = [];
55 |
56 | /**
57 | * @var Feature $feature
58 | */
59 | foreach ($allFeatures as $feature) {
60 | $mappedFeatures[] = [
61 | 'label' => $feature->getFeatureName(),
62 | 'value' => $this->persistenceManager->getIdentifierByObject($feature),
63 | 'icon' => 'icon-cog'
64 | ];
65 | }
66 |
67 | return $mappedFeatures;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Classes/Domain/Comparator/UserIdentifierComparator.php:
--------------------------------------------------------------------------------
1 | comparisonValue !== null) {
19 | return $this->comparisonValue;
20 | }
21 |
22 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'no agent';
23 | $acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'no accept language';
24 |
25 | $identifierString = sprintf("%s°%s°%s", $userAgent, $acceptLanguage, $this->getClientIp());
26 | $hash = md5($identifierString);
27 | $sixLastHexDigits = substr($hash, -6);
28 |
29 | $this->comparisonValue = HexToIntDowncast::sixDigitHexToPercentageInteger($sixLastHexDigits);
30 | return $this->comparisonValue;
31 | }
32 |
33 | /**
34 | * @return string
35 | */
36 | private function getClientIp()
37 | {
38 | $ip = $_SERVER['REMOTE_ADDR'];
39 | if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && preg_match_all('#\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) {
40 | foreach ($matches[0] as $xip) {
41 | if (!preg_match('#^(10|172\.16|192\.168)\.#', $xip)) {
42 | $ip = $xip;
43 | break;
44 | }
45 | }
46 | } elseif (isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) {
47 | $ip = $_SERVER['HTTP_CLIENT_IP'];
48 | } elseif (isset($_SERVER['HTTP_CF_CONNECTING_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CF_CONNECTING_IP'])) {
49 | $ip = $_SERVER['HTTP_CF_CONNECTING_IP'];
50 | } elseif (isset($_SERVER['HTTP_X_REAL_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_X_REAL_IP'])) {
51 | $ip = $_SERVER['HTTP_X_REAL_IP'];
52 | }
53 |
54 | return $ip;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/DeciderList.html:
--------------------------------------------------------------------------------
1 |
47 |
--------------------------------------------------------------------------------
/Resources/Private/Partials/Module/FeatureModule/DecisionList.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Migrations/Postgresql/Version20230301144616.php:
--------------------------------------------------------------------------------
1 | abortIf(
18 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQL100Platform,
19 | "Migration can only be executed safely on 'PostgreSQL'."
20 | );
21 |
22 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_decision (persistence_object_identifier VARCHAR(40) NOT NULL, feature VARCHAR(40) DEFAULT NULL, deciderclassname VARCHAR(255) NOT NULL, decision JSON NOT NULL, defaultdecision VARCHAR(255) NOT NULL, PRIMARY KEY(persistence_object_identifier))');
23 | $this->addSql('CREATE INDEX IDX_8E96F03A1FD77566 ON wysiwyg_abtesting_domain_model_decision (feature)');
24 | $this->addSql('COMMENT ON COLUMN wysiwyg_abtesting_domain_model_decision.decision IS \'(DC2Type:json_array)\'');
25 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_feature (persistence_object_identifier VARCHAR(40) NOT NULL, featurename VARCHAR(255) NOT NULL, active BOOLEAN NOT NULL, PRIMARY KEY(persistence_object_identifier))');
26 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD CONSTRAINT FK_8E96F03A1FD77566 FOREIGN KEY (feature) REFERENCES wysiwyg_abtesting_domain_model_feature (persistence_object_identifier) NOT DEFERRABLE INITIALLY IMMEDIATE');
27 | }
28 |
29 | public function down(Schema $schema): void
30 | {
31 | $this->abortIf(
32 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQL100Platform,
33 | "Migration can only be executed safely on 'PostgreSQL'."
34 | );
35 |
36 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP CONSTRAINT FK_8E96F03A1FD77566');
37 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_decision');
38 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_feature');
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Migrations/Mysql/Version20180703153541.php:
--------------------------------------------------------------------------------
1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
26 |
27 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_decision (persistence_object_identifier VARCHAR(40) NOT NULL, feature VARCHAR(40) DEFAULT NULL, decider VARCHAR(255) NOT NULL, decision LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', defaultdecision VARCHAR(255) NOT NULL, priority INT NOT NULL, INDEX IDX_8E96F03A1FD77566 (feature), PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
28 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_feature (persistence_object_identifier VARCHAR(40) NOT NULL, featurename VARCHAR(255) NOT NULL, active TINYINT(1) NOT NULL, PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB');
29 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD CONSTRAINT FK_8E96F03A1FD77566 FOREIGN KEY (feature) REFERENCES wysiwyg_abtesting_domain_model_feature (persistence_object_identifier)');
30 | }
31 |
32 | /**
33 | * @param Schema $schema
34 | *
35 | * @return void
36 | * @throws \Doctrine\DBAL\Exception
37 | */
38 | public function down(Schema $schema): void
39 | {
40 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".');
41 |
42 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP FOREIGN KEY FK_8E96F03A1FD77566');
43 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_decision');
44 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_feature');
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Classes/Domain/Model/Decision.php:
--------------------------------------------------------------------------------
1 | deciderClassName;
43 | }
44 |
45 | /**
46 | * @param string $deciderClassName
47 | */
48 | public function setDeciderClassName(string $deciderClassName)
49 | {
50 | $this->deciderClassName = $deciderClassName;
51 | }
52 |
53 | /**
54 | * @return Feature
55 | */
56 | public function getFeature()
57 | {
58 | return $this->feature;
59 | }
60 |
61 | /**
62 | * @param Feature $feature
63 | */
64 | public function setFeature($feature)
65 | {
66 | $this->feature = $feature;
67 | }
68 |
69 | /**
70 | * @return array
71 | */
72 | public function getDecision()
73 | {
74 | return $this->decision;
75 | }
76 |
77 | /**
78 | * @param array $decision
79 | */
80 | public function setDecision($decision)
81 | {
82 | $this->decision = $decision;
83 | }
84 |
85 | /**
86 | * @return string
87 | */
88 | public function getDefaultDecision(): string
89 | {
90 | return $this->defaultDecision;
91 | }
92 |
93 | /**
94 | * @param string $defaultDecision
95 | */
96 | public function setDefaultDecision(string $defaultDecision): void
97 | {
98 | $this->defaultDecision = $defaultDecision;
99 | }
100 |
101 | /**
102 | * @return DeciderInterface
103 | */
104 | public function getDecider()
105 | {
106 | return new $this->deciderClassName;
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/Classes/Domain/Service/FeatureService.php:
--------------------------------------------------------------------------------
1 | contextFactory->create()]);
57 | $currentSiteNode = $flowQuery->get(0)->getCurrentSiteNode();
58 |
59 | $q = new FlowQuery([$currentSiteNode]);
60 |
61 | $featureId = $this->persistenceManager->getIdentifierByObject($feature);
62 | $foundContainer = $q->find('[instanceof Wysiwyg.ABTesting:ABTestingContainer][abTest][abTest="' . $featureId . '"]')->get();
63 |
64 | $pageNodes = [];
65 |
66 | /**
67 | * @var Node $container
68 | */
69 | foreach ($foundContainer as $container) {
70 | $parent = $container->getParent()->getParent();
71 | $pageNodes[] = $parent;
72 | }
73 |
74 | return $pageNodes;
75 |
76 | }
77 |
78 | /**
79 | * Wrapper method to get allActiveFeatures.
80 | *
81 | * @return array
82 | */
83 | public function getAllActiveFeatures(): array
84 | {
85 | return $this->featureRepository->getAllActiveFeatures();
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Resources/Private/Script/AbTesting.js:
--------------------------------------------------------------------------------
1 | const abTesting = {
2 | abTestingCookieString: "",
3 | cookieName: document.body.dataset.abTestingCookieName || "WYSIWYG_AB_TESTING",
4 | abTestingObject: {},
5 |
6 | init: function () {
7 | this.abTestingCookieString = this.getCookie();
8 | this.abTestingObject = JSON.parse(this.abTestingCookieString || "{}");
9 | },
10 |
11 | /**
12 | * returns the testing cookie value
13 | *
14 | * @returns {string}
15 | */
16 | getCookie: function () {
17 | let match = document.cookie.match(new RegExp("(^| )" + this.cookieName + "=([^;]+)"));
18 |
19 | if (!match) {
20 | return undefined;
21 | }
22 |
23 | return decodeURIComponent(match[2]);
24 | },
25 |
26 | /**
27 | * This function builds an array of TrackingStrings.
28 | *
29 | * Example:
30 | * [
31 | * 'featureX_a',
32 | * 'featureY_b'
33 | * ]
34 | *
35 | * @returns {Array}
36 | */
37 | getTrackingStringsArrayForAllFeatures: function () {
38 | let trackingStringsArray = [];
39 |
40 | for (let featureName in this.abTestingObject) {
41 | trackingStringsArray.push(this.getTrackingStringForFeature(featureName));
42 | }
43 |
44 | return trackingStringsArray;
45 | },
46 |
47 | /**
48 | * This functions concat a featureName with a decision separated by an underscore ('_') for a given feature by featureName.
49 | *
50 | * Example: 'feature_a'
51 | *
52 | * @param featureName
53 | * @returns {string}
54 | */
55 | getTrackingStringForFeature: function (featureName) {
56 | return featureName + "_" + this.getDecisionForFeature(featureName);
57 | },
58 |
59 | /**
60 | * This function returns an abTestingObject.
61 | * An Object has a featureName for every key and the value represents the decision for a feature.
62 | *
63 | * Example:
64 | * {
65 | * featureX: 'a',
66 | * featureY: 'b'
67 | * }
68 | *
69 | * @returns {WY.AbTesting.abTestingObject|{}}
70 | */
71 | getDecisionsForAllFeatures: function () {
72 | return this.abTestingObject;
73 | },
74 |
75 | /**
76 | * Searches the abTestObject for a property which matches the featureName and return its value.
77 | *
78 | * @param featureName
79 | * @returns {string}
80 | */
81 | getDecisionForFeature: function (featureName) {
82 | if (featureName in this.abTestingObject) {
83 | return this.abTestingObject[featureName];
84 | }
85 | },
86 | };
87 |
88 | window.WY = window.WY || {};
89 | window.WY.AbTesting = abTesting;
90 |
91 | window.addEventListener('load', function () {
92 | window.WY.AbTesting.init();
93 | });
94 |
--------------------------------------------------------------------------------
/Classes/Eel/DecisionHelper.php:
--------------------------------------------------------------------------------
1 | decisionService->getDecisionForFeature($feature);
40 | }
41 |
42 | /**
43 | * Returns a decision for a Feature by featureName.
44 | *
45 | * @param string $featureName
46 | * @param string $forcedDecision
47 | * @return string
48 | */
49 | public function getDecisionForFeatureByName(string $featureName, $forcedDecision = null)
50 | {
51 | if ($forcedDecision) {
52 | return $forcedDecision;
53 | }
54 |
55 | $foundFeature = $this->featureRepository->findOneByFeatureName($featureName);
56 | return ($foundFeature instanceof Feature) ? $this->getDecisionForFeature($foundFeature, $forcedDecision) : '';
57 | }
58 | /**
59 | * Returns a decision for a Feature by featureName.
60 | *
61 | * @param string $featurePersistentIdentifier
62 | * @param string $forcedDecision
63 | * @return string
64 | */
65 | public function getDecisionForFeatureByIdentifier(string $featurePersistentIdentifier, $forcedDecision = null)
66 | {
67 | if ($forcedDecision) {
68 | return $forcedDecision;
69 | }
70 |
71 | $foundFeature = $this->featureRepository->findByIdentifier($featurePersistentIdentifier);
72 | return ($foundFeature instanceof Feature) ? $this->getDecisionForFeature($foundFeature, $forcedDecision) : '';
73 | }
74 |
75 | /**
76 | * @return string[]
77 | */
78 | public function getAllDecisions()
79 | {
80 | return $this->decisionService->decideForAllFeatures();
81 | }
82 |
83 | /**
84 | * @param string $methodName
85 | * @return boolean
86 | */
87 | public function allowsCallOfMethod($methodName)
88 | {
89 | switch ($methodName) {
90 | case 'getDecisionForFeature':
91 | case 'getAllDecisions':
92 | case 'getDecisionForFeatureByName':
93 | case 'getDecisionForFeatureByIdentifier':
94 | return true;
95 | }
96 |
97 | return false;
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/Resources/Private/Templates/Module/AbTesting/FeatureModule/ListFeatures.html:
--------------------------------------------------------------------------------
1 | {namespace neos=Neos\Neos\ViewHelpers}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | | A/B Test |
11 | Status |
12 | |
13 |
14 |
15 |
16 |
17 | | {feature.featureName} |
18 |
19 |
20 |
21 | Active
22 |
23 |
24 | Inactive
25 |
26 |
27 | |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | |
55 |
56 |
57 |
58 |
59 |
62 |
63 |
--------------------------------------------------------------------------------
/Classes/Domain/Http/Middleware/AbTestingCookieMiddleware.php:
--------------------------------------------------------------------------------
1 | handle($request);
49 |
50 | $cookieParams = $request->getCookieParams();
51 |
52 | if (!array_key_exists($this->cookieSettings['name'], $cookieParams)) {
53 | return $this->createCookieToResponse($response);
54 | }
55 |
56 | return $this->refreshResponseCookie($request, $response);
57 | }
58 |
59 | /**
60 | * Creates a new A/B Testing Cookie with decisions of all features.
61 | *
62 | * @param ResponseInterface $response
63 | *
64 | * @return ResponseInterface
65 | */
66 | protected function createCookieToResponse(ResponseInterface $response): ResponseInterface
67 | {
68 | $abTestingCookie = new Cookie($this->cookieSettings['name'], null, strtotime($this->cookieSettings['lifetime']), null, null, '/', false, false);
69 | $decisionsAsJson = json_encode($this->decisionService->decideForAllFeatures());
70 | $abTestingCookie->setValue($decisionsAsJson);
71 |
72 | return $response->withAddedHeader('Set-Cookie', (string)$abTestingCookie);
73 | }
74 |
75 | /**
76 | * Refreshes the A/B Testing Cookie, if necessary.
77 | * Checks for current decisions and add new decisions for features without a decision to the cookie.
78 | *
79 | * @param ServerRequestInterface $request
80 | * @param ResponseInterface $response
81 | *
82 | * @return ResponseInterface
83 | */
84 | protected function refreshResponseCookie(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
85 | {
86 | $cookieParams = $request->getCookieParams();
87 | $abTestingCookie = $cookieParams[$this->cookieSettings['name']] ?? '';
88 | $currentCookieValue = json_decode(urldecode(urldecode($abTestingCookie)), true);
89 | $activeFeatures = $this->featureService->getAllActiveFeatures();
90 |
91 | if (is_array($currentCookieValue)) {
92 | /** @var Feature $activeFeature */
93 | foreach ($activeFeatures as $activeFeature) {
94 | $featureName = str_replace(' ', '_', $activeFeature->getFeatureName());
95 |
96 | if (!array_key_exists($featureName, $currentCookieValue)) {
97 | $currentCookieValue[$featureName] = $this->decisionService->getDecisionForFeature($activeFeature);
98 | }
99 | }
100 | }
101 |
102 | $abTestingCookie = new Cookie($this->cookieSettings['name'], null, strtotime($this->cookieSettings['lifetime']), null, null, '/', false, false);
103 | $decisionsAsJson = json_encode($currentCookieValue);
104 | $abTestingCookie->setValue($decisionsAsJson);
105 |
106 | return $response->withAddedHeader('Set-Cookie', (string)$abTestingCookie);
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!CAUTION]
2 | > This repository is deprecated and will be removed in September 2025
3 |
4 | # wysiwyg\* - Neos A/B Testing
5 |
6 | 
7 | 
8 | 
9 |
10 | This package provides a simple to use backend module and frontend container to run A/B tests in Neos.
11 |
12 | ## Installation
13 |
14 | Run these commands to install the package and update the database schema.
15 |
16 | ```bash
17 | composer require wy/neos-abtesting
18 |
19 | ./flow flow:doctrine:migrate
20 | ```
21 |
22 | ## Usage
23 |
24 | 
25 |
26 | 
27 |
28 | This package offers a node container for displaying two different nodes for two different decisions (A or B).
29 |
30 | You can add the A/B testing container to your constraints: `Wysiwyg.ABTesting:ABTestingContainer`
31 |
32 | The container has three contentCollections:
33 |
34 | - itemsa
35 | - itemsb
36 | - itemsc
37 |
38 | By default these collections accept all content nodes.
39 | This can be changed by overriding the respective node configuration in your own NodeTypes.yaml file.
40 | An editor has to put one or more nodes in each content collection.
41 |
42 | **IMPORTANT**
43 | Both versions will always be rendered in the Neos backend.
44 | Per default version A will be displayed in the frontend if no feature has been configured and selected.
45 |
46 | You can find an option group "A / B Testing" in each ABTestingContainer.
47 | This group provides a dropdown menu to choose which feature will be used for the container.
48 |
49 | #### Preview Different Versions
50 |
51 | Sometimes it is necessary to view a different version of the feature.
52 | For viewing a different version add the "forceABVersion" GET-parameter into the url and assign the desired version as the value.
53 |
54 | Example:
55 | Show Version A
56 | `https://example.com/greatFeature.html?forceABVersion=a`
57 | Show Version B
58 | `https://example.com/greatFeature.html?forceABVersion=b`
59 | Show Version C
60 | `https://example.com/greatFeature.html?forceABVersion=c`
61 |
62 | This parameter will not override the cookie value.
63 |
64 | ### Backend-Module Usage
65 |
66 | You will find a new "A/B Testing" menu item in the main menu of the Neos backend.
67 | The module "Features" will offer all necessary functions to manage A/B testing features.
68 | In the A/B testing dashboard you will find the following options:
69 |
70 | - "Create Feature": Add a new A/B test feature
71 | - "Feature List": Shows a list of all A/B test features
72 |
73 | **Feature List**
74 | 
75 |
76 | ## Settings
77 |
78 | This package uses default values for creating a cookie.
79 | There are several settings which can be modified for your own implementation.
80 |
81 | ```yaml
82 | Wysiwyg:
83 | ABTesting:
84 | cookie:
85 | name: "WYSIWYG_AB_TESTING"
86 | lifetime: "+2 years"
87 | ```
88 |
89 | Per default the cookie has a lifetime of 2 years. Please consider that [strtotime()](https://www.php.net/manual/de/function.strtotime.php) is used to evaluate the lifetime setting's value if you need to adjust it.
90 |
91 | ## Regarding Privacy (i.e. GDPR)
92 |
93 | Please note that all A/B testing decisions will be saved in a cookie that by default is named "WYSIWYG_AB_TESTING" and has a lifetime of 2 years.
94 | This cookie will be created whenever a user opens the webpage for the first time.
95 | It contains a raw JSON string which includes all names of the features and their decision (a or b).
96 | Whenever a user enters the page and already has the cookie, it will be made sure that all active features are saved with a decision. If there are new features they will be added to the cookie and a new JSON string will be saved with all decisions.
97 |
98 | ## Contributing
99 |
100 | Pull requests are welcome. For major changes please open an issue first to discuss what you would like to change.
101 |
102 | ## Planned Features
103 |
104 | We want to enhance the A/B testing with more solid features.
105 |
106 | - Decider-Chaining
107 | Right now it's only possible to add one decision to a feature.
108 | We want to make it possible to add a chaining of deciders for example DimensionDecision AND Percentage.
109 |
110 | ## Authors
111 |
112 | [Sven Wütherich](https://github.com/svwu)
113 | [Alexander Schulte](https://github.com/Alex-Schulte)
114 | [Eva-Maria Müller](https://github.com/emmue)
115 | [Marvin Kuhn](https://github.com/breadlesscode)
116 |
117 | ## License
118 |
119 | This package is released under the MIT License (MIT). Please see [License File](LICENSE) for more information.
120 |
--------------------------------------------------------------------------------
/Classes/Domain/Service/DecisionService.php:
--------------------------------------------------------------------------------
1 | getFeatureName();
58 | $decisionFromCookie = $this->getDecisionFromCookies($featureName);
59 | $decisionsForFeature = $this->decisionRepository->findByFeature($feature);
60 |
61 | if ($decisionFromCookie) {
62 | return $decisionFromCookie;
63 | }
64 |
65 | // We want to reuse the same comparator here
66 | $comparator = $this->getComparator();
67 | $decision = '';
68 |
69 | /**
70 | * @var Decision $singleDecision
71 | */
72 | foreach ($decisionsForFeature as $singleDecision) {
73 | $decider = $singleDecision->getDecider();
74 | $decision = $decider->decide($singleDecision->getDecision(), $comparator);
75 |
76 | if (!$decision) {
77 | return '';
78 | }
79 | }
80 |
81 | return $decision;
82 | }
83 |
84 | /**
85 | * Returns all Decider Class Names without any Namespaces.
86 | *
87 | * @return array
88 | */
89 | public function getAllDeciderObjects(): array
90 | {
91 | $featureFactory = new DeciderFactory();
92 |
93 | $deciderObjects = [];
94 |
95 | foreach ($featureFactory->getAllDecider() as $deciderClassName) {
96 | $lastSlashPosition = strrpos($deciderClassName, '\\');
97 | $deciderName = substr($deciderClassName, $lastSlashPosition + 1);
98 |
99 | $deciderObject = new DeciderObject();
100 | $deciderObject->setDeciderClass($deciderClassName);
101 | $deciderObject->setDeciderName($deciderName);
102 |
103 | $deciderObjects[] = $deciderObject;
104 | }
105 |
106 | return $deciderObjects;
107 | }
108 |
109 | /**
110 | * Decides for all features which are configured in database.
111 | *
112 | * @return string[]
113 | */
114 | public function decideForAllFeatures(): array
115 | {
116 | $features = $this->featureRepository->findAll();
117 |
118 | $decisions = [];
119 |
120 | /**
121 | * @var Feature $feature
122 | */
123 | foreach ($features as $feature) {
124 | if ($feature->isActive()) {
125 |
126 | $featureName = str_replace(' ', '_', $feature->getFeatureName());
127 |
128 | $decisions[$featureName] = $this->getDecisionForFeature($feature);
129 | }
130 | }
131 |
132 | return $decisions;
133 | }
134 |
135 | /**
136 | * @param string $featureName
137 | * @return string
138 | */
139 | public function getDecisionFromCookies(string $featureName): string
140 | {
141 | $cookieName = $this->cookieSettings['name'] ?? 'WYSIWYG_AB_TESTING';
142 |
143 | if (array_key_exists($cookieName, $_COOKIE)) {
144 | $decisionsArray = json_decode(urldecode($_COOKIE[$cookieName]), true);
145 |
146 | if (is_array($decisionsArray)) {
147 | return array_key_exists($featureName, $decisionsArray) ? $decisionsArray[$featureName] : '';
148 | }
149 | }
150 |
151 | return '';
152 | }
153 |
154 | /**
155 | * Create the comparator to be used for decisions. Can be configured
156 | * TODO: Should probably be a factory that can be injected
157 | *
158 | * @return ComparatorInterface
159 | */
160 | protected function getComparator(): ComparatorInterface
161 | {
162 | return new $this->configuredComparatorClass;
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/Classes/Controller/Module/AbTesting/FeatureModuleController.php:
--------------------------------------------------------------------------------
1 | redirect('listFeatures');
47 | }
48 |
49 | public function newFeatureAction()
50 | {
51 |
52 | }
53 |
54 | /**
55 | * @param Feature $feature
56 | *
57 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
58 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
59 | */
60 | public function createFeatureAction(Feature $feature)
61 | {
62 | $existingFeature = $this->featureRepository->findOneByFeatureName($feature->getFeatureName());
63 |
64 | if (is_null($existingFeature)) {
65 | $this->featureRepository->add($feature);
66 | }
67 |
68 | $this->redirect('listFeatures');
69 | }
70 |
71 | public function listFeaturesAction()
72 | {
73 | $allFeatures = $this->featureRepository->findAll();
74 |
75 | $this->view->assign('allFeatures', $allFeatures);
76 | }
77 |
78 | /**
79 | * @param Feature $feature
80 | *
81 | * @throws \Neos\Eel\Exception
82 | */
83 | public function showFeatureAction(Feature $feature)
84 | {
85 | $decisions = $this->decisionRepository->findByFeature($feature);
86 | $pages = $this->featureService->getPagesWithFeature($feature);
87 |
88 | $this->view->assignMultiple([
89 | 'decisions' => $decisions,
90 | 'feature' => $feature,
91 | 'pages' => $pages
92 | ]);
93 | }
94 |
95 | /**
96 | * @param Feature $feature
97 | *
98 | * @throws \Neos\Eel\Exception
99 | */
100 | public function deleteFeatureAction(Feature $feature)
101 | {
102 | $pages = $this->featureService->getPagesWithFeature($feature);
103 | $deletable = count($pages) == 0;
104 |
105 | $this->view->assignMultiple([
106 | 'feature' => $feature,
107 | 'deletable' => $deletable,
108 | 'pages' => $pages
109 | ]);
110 | }
111 |
112 | /**
113 | * @param Feature $feature
114 | *
115 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
116 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
117 | */
118 | public function deleteFeatureConfirmedAction(Feature $feature)
119 | {
120 | $this->featureRepository->remove($feature);
121 | $decisions = $this->decisionRepository->findByFeature($feature);
122 |
123 | foreach ($decisions as $decision) {
124 | $this->decisionRepository->remove($decision);
125 | }
126 |
127 | $this->redirect('index');
128 | }
129 |
130 | /**
131 | * @param Feature $feature
132 | */
133 | public function editFeatureAction(Feature $feature)
134 | {
135 | $decisions = $this->decisionRepository->findByFeature($feature);
136 |
137 | $this->view->assignMultiple([
138 | 'decisions' => $decisions,
139 | 'feature' => $feature
140 | ]);
141 | }
142 |
143 | /**
144 | * @param Feature $feature
145 | *
146 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
147 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
148 | */
149 | public function updateFeatureAction(Feature $feature)
150 | {
151 | if ($feature) {
152 | $this->featureRepository->update($feature);
153 | }
154 |
155 | $this->redirect('listFeatures');
156 | }
157 |
158 | /**
159 | * @param Feature $feature
160 | *
161 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
162 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
163 | */
164 | public function toggleActiveAction(Feature $feature)
165 | {
166 | $feature->setActive(!$feature->isActive());
167 |
168 | $this->featureRepository->update($feature);
169 | $this->persistenceManager->persistAll();
170 |
171 | $this->redirect('listFeatures');
172 | }
173 |
174 | /**
175 | * @param Feature $feature
176 | */
177 | public function showPagesAction(Feature $feature)
178 | {
179 | $pages = $this->featureService->getPagesWithFeature($feature);
180 | $this->view->assignMultiple([
181 | 'feature' => $feature,
182 | 'pages' => $pages
183 | ]);
184 | }
185 |
186 | /**
187 | * @param Feature $feature
188 | */
189 | public function chooseDeciderAction(Feature $feature)
190 | {
191 | $deciderObjectsRaw = $this->decisionService->getAllDeciderObjects();
192 |
193 | $assignableDeciderObjects = [];
194 | $deciderToIgnore = [];
195 |
196 | /** @var Decision $decision */
197 | foreach ($feature->getDecisions() as $decision) {
198 | $deciderToIgnore[] = $decision->getDecider();
199 | }
200 |
201 | /** @var DeciderObject $deciderObject */
202 | foreach ($deciderObjectsRaw as $deciderObject) {
203 | if (!in_array($deciderObject->getDeciderName(), $deciderToIgnore)) {
204 | $assignableDeciderObjects[] = $deciderObject;
205 | }
206 | }
207 |
208 | $this->view->assignMultiple([
209 | 'feature' => $feature,
210 | 'deciderObjects' => $assignableDeciderObjects
211 | ]);
212 | }
213 |
214 | /**
215 | * @param DeciderObject $decider
216 | */
217 | public function addDecisionToFeatureAction(DeciderObject $decider)
218 | {
219 | $deciderClass = $decider->getDeciderClass();
220 | $deciderObject = new $deciderClass;
221 |
222 | $this->view->assignMultiple([
223 | 'feature' => $decider->getFeature(),
224 | 'decider' => $deciderObject,
225 | 'deciderClass' => $deciderClass
226 | ]);
227 | }
228 |
229 | /**
230 | * @param Decision $decision
231 | *
232 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
233 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
234 | */
235 | public function saveDecisionToFeatureAction(Decision $decision)
236 | {
237 | $this->decisionRepository->add($decision);
238 | $this->addFlashMessage('Decider has been added.');
239 | $this->redirect('listFeatures');
240 | }
241 |
242 | /**
243 | * @param Decision $decision
244 | */
245 | public function editDecisionAction(Decision $decision)
246 | {
247 | $this->view->assign('decision', $decision);
248 | }
249 |
250 | /**
251 | * @param Decision $decision
252 | *
253 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
254 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
255 | */
256 | public function updateDecisionAction(Decision $decision)
257 | {
258 | $total = 0;
259 | foreach ($decision->getDecision() as $version => $percentage) {
260 | $total += $percentage;
261 | }
262 |
263 | if ($total !== 100) {
264 | $this->addFlashMessage('Please configure 100% in total for all versions.', '', Error\Message::SEVERITY_ERROR);
265 | $this->redirect('editDecision', null, null, ['decision' => $decision]);
266 | }
267 |
268 | $this->addFlashMessage('A/B Test successfully configured.');
269 | $this->decisionRepository->update($decision);
270 | $this->redirect('listFeatures');
271 | }
272 |
273 | /**
274 | * @param Decision $decision
275 | */
276 | public function deleteDecisionAction(Decision $decision)
277 | {
278 | $this->view->assign('decision', $decision);
279 | }
280 |
281 | /**
282 | * @param Decision $decision
283 | *
284 | * @throws \Neos\Flow\Mvc\Exception\StopActionException
285 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
286 | */
287 | public function deleteDecisionConfirmedAction(Decision $decision)
288 | {
289 | $this->decisionRepository->remove($decision);
290 | $this->addFlashMessage('Decision has been deleted.', '', Error\Message::SEVERITY_NOTICE);
291 | $this->redirect('listFeatures');
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/Tests/Unit/Domain/Decider/AbstractPercentageDeciderTest.php:
--------------------------------------------------------------------------------
1 | 100,
20 | 'b' => 0
21 | ]
22 | ];
23 | }
24 |
25 | /**
26 | * DataProvider: A/B: 0 - 100
27 | *
28 | * @return array
29 | */
30 | public function hundredPercentBProvider()
31 | {
32 | return [
33 | [
34 | 'a' => 0,
35 | 'b' => 100
36 | ]
37 | ];
38 | }
39 |
40 | /**
41 | * DataProvider: A/B: 30 - 70
42 | *
43 | * @return array
44 | */
45 | public function thirtyASeventyBProvider()
46 | {
47 | return [
48 | [
49 | 'a' => 30,
50 | 'b' => 70
51 | ]
52 | ];
53 | }
54 |
55 | /**
56 | * DataProvider: A/B/C: 0 - 0 - 100
57 | *
58 | * @return array
59 | */
60 | public function hundredPercentCProvider()
61 | {
62 | return [
63 | [
64 | 'a' => 0,
65 | 'b' => 0,
66 | 'c' => 100
67 | ]
68 | ];
69 | }
70 |
71 | /**
72 | * DataProvider: A/B/C: 0 - 50 - 50
73 | *
74 | * @return array
75 | */
76 | public function zeroAFiftyBFiftyCProvider()
77 | {
78 | return [
79 | [
80 | 'a' => 0,
81 | 'b' => 50,
82 | 'c' => 50
83 | ]
84 | ];
85 | }
86 |
87 | /**
88 | * DataProvider: A/B/C: 50 - 0 - 50
89 | *
90 | * @return array
91 | */
92 | public function fiftyAZeroBFiftyCProvider()
93 | {
94 | return [
95 | [
96 | 'a' => 50,
97 | 'b' => 0,
98 | 'c' => 50
99 | ]
100 | ];
101 | }
102 |
103 | /**
104 | * DataProvider: A/B/C: 33 - 33 - 33
105 | *
106 | * @return array
107 | */
108 | public function balancedAbcProvider()
109 | {
110 | return [
111 | [
112 | 'a' => 33,
113 | 'b' => 33,
114 | 'c' => 33
115 | ]
116 | ];
117 | }
118 |
119 | /**
120 | * DataProvider, where a is never 0%
121 | *
122 | * Provided Test cases:
123 | * a < b, c = 0
124 | * a < b, a > c
125 | * a > b, a < c
126 | * a > b, a > c
127 | * a = b, c = 0
128 | * a > b, c = 0
129 | * a > c, b = 0
130 | * a < c, b = 0
131 | * a = c, b = 0
132 | * a = 100, b = 0, c = 0
133 | *
134 | * @return array
135 | */
136 | public function unbalancedAbcProviderForA()
137 | {
138 | return [
139 | // a < b, c = 0
140 | [
141 | 'a' => 5,
142 | 'b' => 95,
143 | 'c' => 0
144 | ],
145 | // a < b, a > c
146 | [
147 | 'a' => 20,
148 | 'b' => 70,
149 | 'c' => 10
150 | ],
151 | // a > b, a < c
152 | [
153 | 'a' => 20,
154 | 'b' => 10,
155 | 'c' => 70
156 | ],
157 | // a > b, a > c
158 | [
159 | 'a' => 50,
160 | 'b' => 10,
161 | 'c' => 40
162 | ],
163 | // a = b, c = 0
164 | [
165 | 'a' => 50,
166 | 'b' => 50,
167 | 'c' => 0
168 | ],
169 | // a > b, c = 0
170 | [
171 | 'a' => 70,
172 | 'b' => 30,
173 | 'c' => 0
174 | ],
175 | // a > c, b = 0
176 | [
177 | 'a' => 70,
178 | 'b' => 0,
179 | 'c' => 30
180 | ],
181 | // a < c, b = 0
182 | [
183 | 'a' => 30,
184 | 'b' => 0,
185 | 'c' => 70
186 | ],
187 | // a = c, b = 0
188 | [
189 | 'a' => 50,
190 | 'b' => 0,
191 | 'c' => 50
192 | ],
193 | // a = 100, b = 0, c = 0
194 | [
195 | 'a' => 100,
196 | 'b' => 0,
197 | 'c' => 0
198 | ]
199 | ];
200 | }
201 |
202 |
203 | /**
204 | * DataProvider, where b is never 0%
205 | *
206 | * Provided Test cases:
207 | * b > a, b > c
208 | * b < a, b > c
209 | * b > a, b < c
210 | * b < a, b < c
211 | * b > a, c = 0
212 | * b < a, c = 0
213 | * b = a, c = 0
214 | * b = c, a = 0
215 | * b > c, a = 0
216 | * b < c, a = 0
217 | * b = 100, a = 0, c = 0
218 | *
219 | * @return array
220 | */
221 | public function unbalancedAbcProviderForB()
222 | {
223 | return [
224 | // b > a, b > c
225 | [
226 | 'a' => 10,
227 | 'b' => 70,
228 | 'c' => 20
229 | ],
230 | // b < a, b > c
231 | [
232 |
233 | 'a' => 10,
234 | 'b' => 50,
235 | 'c' => 40
236 | ],
237 | // b > a, b < c
238 | [
239 | 'a' => 10,
240 | 'b' => 20,
241 | 'c' => 70
242 | ],
243 | // b < a, b < c
244 | [
245 | 'a' => 20,
246 | 'b' => 30,
247 | 'c' => 50
248 | ],
249 | // b > a, c = 0
250 | [
251 | 'a' => 30,
252 | 'b' => 70,
253 | 'c' => 0
254 | ],
255 | // b < a, c = 0
256 | [
257 | 'a' => 70,
258 | 'b' => 30,
259 | 'c' => 0
260 | ],
261 | // b > c, a = 0
262 | [
263 | 'a' => 0,
264 | 'b' => 60,
265 | 'c' => 40
266 | ],
267 | // b < c, a = 0
268 | [
269 | 'a' => 0,
270 | 'b' => 40,
271 | 'c' => 60
272 | ],
273 | // b = a, c = 0
274 | [
275 | 'a' => 50,
276 | 'b' => 50,
277 | 'c' => 0
278 | ],
279 | // b = c, a = 0
280 | [
281 | 'a' => 0,
282 | 'b' => 50,
283 | 'c' => 50
284 | ],
285 | // b = 100, a = 0, c = 0
286 | [
287 | 'a' => 0,
288 | 'b' => 100,
289 | 'c' => 0
290 | ]
291 | ];
292 | }
293 |
294 | /**
295 | * DataProvider, where c is never 0%
296 | *
297 | * Provided data for Test cases:
298 | * c < a, b < a
299 | * c > a, b < a
300 | * c < a, b > a
301 | * c > a, b > a
302 | * c > a, b = 0
303 | * c < a, b = 0
304 | * c < b, a = 0
305 | * c > b, a = 0
306 | * c = a, b = 0
307 | * c = b, a = 0
308 | * c = 100, a = 0, b = 0
309 | *
310 | * @return array
311 | */
312 | public function unbalancedAbcProviderForC()
313 | {
314 | return [
315 | // c < a, b < a
316 | [
317 | 'a' => 60,
318 | 'b' => 10,
319 | 'c' => 30
320 | ],
321 | // c > a, b < a
322 | [
323 | 'a' => 30,
324 | 'b' => 10,
325 | 'c' => 60
326 | ],
327 | // c < a, b > a
328 | [
329 | 'a' => 30,
330 | 'b' => 50,
331 | 'c' => 20
332 | ],
333 | // c > a, b > a
334 | [
335 | 'a' => 10,
336 | 'b' => 70,
337 | 'c' => 20
338 | ],
339 | // c > a, b = 0
340 | [
341 | 'a' => 20,
342 | 'b' => 0,
343 | 'c' => 80
344 | ],
345 | // c < a, b = 0
346 | [
347 | 'a' => 30,
348 | 'b' => 0,
349 | 'c' => 70
350 | ],
351 | // c < b, a = 0
352 | [
353 | 'a' => 0,
354 | 'b' => 70,
355 | 'c' => 30
356 | ],
357 | // c > b, a = 0
358 | [
359 | 'a' => 0,
360 | 'b' => 30,
361 | 'c' => 70
362 | ],
363 | // c = a, b = 0
364 | [
365 | 'a' => 50,
366 | 'b' => 0,
367 | 'c' => 50
368 | ],
369 | // c = b, a = 0
370 | [
371 | 'a' => 0,
372 | 'b' => 50,
373 | 'c' => 50
374 | ],
375 | // c = 100, a = 0, b = 0
376 | [
377 | 'a' => 0,
378 | 'b' => 0,
379 | 'c' => 100
380 | ]
381 | ];
382 | }
383 |
384 | }
385 |
--------------------------------------------------------------------------------
/Tests/Unit/Domain/Decider/PercentageDeciderTest.php:
--------------------------------------------------------------------------------
1 | decide($this->getProvidedData(), new RandomComparator());
26 |
27 | Assert::assertSame('a', $decision);
28 | }
29 |
30 | /**
31 | * Scenario: A/B - 30 - 70
32 | *
33 | * Test if the decider decides for 'a', when 'a' has 100% configured.
34 | * This test will cover configured decisions for tests where only a and b are configured.
35 | *
36 | * @dataProvider thirtyASeventyBProvider
37 | * @test
38 | */
39 | public function deciderDecidesForAOnThirtyPercent($a, $b)
40 | {
41 | $fixedValueComparator = new FixedValueComparator($a - 1);
42 | $percentageDecider = new PercentageDecider();
43 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
44 |
45 | Assert::assertSame('a', $decision);
46 | }
47 |
48 | /**
49 | * Scenario: A/B - 0 - 100
50 | *
51 | * Test if the decider decides for 'b', when 'b' has 100% configured.
52 | * This test will cover configured decisions for tests where only a and b are configured.
53 | *
54 | * @dataProvider hundredPercentBProvider
55 | * @test
56 | */
57 | public function deciderDecidesForBOnHundredPercent($a, $b)
58 | {
59 | $percentageDecider = new PercentageDecider();
60 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator());
61 |
62 | Assert::assertSame('b', $decision);
63 | }
64 |
65 | /**
66 | * Scenario: A/B/C 0 - 0 - 100
67 | *
68 | * Test if the decider decides for 'c', when 'c' has 100% configured.
69 | * This test will cover configured decisions for tests where a, b and c are configured.
70 | *
71 | * @dataProvider hundredPercentCProvider
72 | * @test
73 | */
74 | public function deciderDecidesForCOnHundredPercent($a, $b, $c)
75 | {
76 | $percentageDecider = new PercentageDecider();
77 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator());
78 |
79 | Assert::assertSame('c', $decision);
80 | }
81 |
82 | /**
83 | * Scenario: A/B/C 0 - 50 - 50
84 | *
85 | * Test if the decider decides for 'b' or 'c', when 'a' has 0 percent configured.
86 | *
87 | * @dataProvider zeroAFiftyBFiftyCProvider
88 | * @test
89 | */
90 | public function deciderAlwaysDecidesForBorC($a, $b, $c)
91 | {
92 | $percentageDecider = new PercentageDecider();
93 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator());
94 |
95 | Assert::assertTrue(in_array($decision, ['b', 'c']));
96 | Assert::assertNotSame('a', $decision);
97 | }
98 |
99 | /**
100 | * Scenario: A/B/C 50 - 0 - 50
101 | *
102 | * Test if the decider decides for 'a' or 'c', when 'b' has 0 percent configured.
103 | *
104 | * @dataProvider fiftyAZeroBFiftyCProvider
105 | * @test
106 | */
107 | public function deciderCanOnlyDecideForAorC($a, $b, $c)
108 | {
109 | $percentageDecider = new PercentageDecider();
110 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator());
111 |
112 | Assert::assertTrue(in_array($decision, ['a', 'c']));
113 | Assert::assertNotSame('b', $decision);
114 | }
115 |
116 | /**
117 | * Scenario: A/B/C 33 - 33 - 33
118 | *
119 | * Test if the decider decides for 'a', 'b' or 'c'.
120 | *
121 | * @dataProvider balancedAbcProvider
122 | * @test
123 | */
124 | public function deciderCanOnlyDecideForAorBorC($a, $b, $c)
125 | {
126 | $percentageDecider = new PercentageDecider();
127 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator());
128 |
129 | Assert::assertTrue(in_array($decision, ['a', 'b', 'c']));
130 | }
131 |
132 | /**
133 | * Scenario: A/B 30 - 70, randomNumber $a -1
134 | *
135 | * Test if the decider only chooses 'a', when the random number is in range of a.
136 | *
137 | * @dataProvider thirtyASeventyBProvider
138 | * @test
139 | */
140 | public function deciderDecidesOnlyForAWhenRandomNumberIsLowerThanB($a, $b)
141 | {
142 | $fixedValueComparator = new FixedValueComparator($a - 1);
143 | $percentageDecider = new PercentageDecider();
144 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
145 | Assert::assertSame('a', $decision);
146 | }
147 |
148 | /**
149 | * Scenario: A/B 30 - 70, randomNumber $a + 1
150 | *
151 | * Test if the decider only chooses 'b', when the random number is in range of b.
152 | *
153 | * @dataProvider thirtyASeventyBProvider
154 | * @test
155 | */
156 | public function deciderDecidesOnlyForBWhenRandomNumberIsHigherThanB($a, $b)
157 | {
158 | $fixedValueComparator = new FixedValueComparator($a + 1);
159 | $percentageDecider = new PercentageDecider();
160 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
161 | Assert::assertSame('b', $decision);
162 | }
163 |
164 |
165 | /**
166 | * Scenario: A/B/C 33 - 33 - 33, randomNumber $a + 1
167 | *
168 | * Test if the decider only chooses 'c', when the random number is in range of b.
169 | *
170 | * @dataProvider balancedAbcProvider
171 | * @test
172 | */
173 | public function deciderDecidesOnlyForBWhenRandomNumberIsHigherThanAAndLowerThanC($a, $b, $c)
174 | {
175 | $fixedValueComparator = new FixedValueComparator($a + 1);
176 | $percentageDecider = new PercentageDecider();
177 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
178 | Assert::assertSame('b', $decision);
179 | }
180 |
181 | /**
182 | * Scenario: A/B/C 33 - 33 - 33, randomNumber $a + $b - 1
183 | *
184 | * @dataProvider balancedAbcProvider
185 | * @test
186 | */
187 | public function deciderDecidesOnlyForBWhenRandomNumberIsLowerThanC($a, $b, $c)
188 | {
189 | $fixedValueComparator = new FixedValueComparator($a + $b - 1);
190 | $percentageDecider = new PercentageDecider();
191 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
192 | Assert::assertSame('b', $decision);
193 | }
194 |
195 | /**
196 | * Scenario: A/B/C 33 - 33 - 33, randomNumber $a +$b + 1
197 | *
198 | * Test if the decider only chooses 'c', when the random number is in range of c.
199 | *
200 | * @dataProvider balancedAbcProvider
201 | * @test
202 | */
203 | public function deciderDecidesOnlyForCWhenRandomNumberIsHigherThanAAndB($a, $b, $c)
204 | {
205 | $fixedValueComparator = new FixedValueComparator($a + $b + 1);
206 | $percentageDecider = new PercentageDecider();
207 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
208 | Assert::assertSame('c', $decision);
209 | }
210 |
211 | /**
212 | * Scenario: several unbalanced A/B/C, randomNumber $a - 1
213 | *
214 | * @dataProvider unbalancedAbcProviderForA
215 | * @test
216 | */
217 | public function deciderDecidesOnlyForAWhenRandomNumberIsLowerThanA($a, $b, $c)
218 | {
219 | $fixedValueComparator = new FixedValueComparator($a - 1);
220 | $percentageDecider = new PercentageDecider();
221 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
222 | Assert::assertSame('a', $decision);
223 | }
224 |
225 | /**
226 | * Scenario: several unbalanced A/B/C, randomNumber $a + 1
227 | *
228 | * @dataProvider unbalancedAbcProviderForB
229 | * @test
230 | */
231 | public function deciderDecidesForBWhenRandomNumberIsHigherThanA($a, $b, $c)
232 | {
233 | $fixedValueComparator = new FixedValueComparator($a + 1);
234 | $percentageDecider = new PercentageDecider();
235 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
236 | Assert::assertSame('b', $decision);
237 | }
238 |
239 | /**
240 | * Scenario: several unbalanced A/B/C, randomNumber $a + $b + 1
241 | *
242 | * @dataProvider unbalancedAbcProviderForC
243 | * @test
244 | */
245 | public function deciderDecidesForCWhenRandomNumberIsHigherThanAndB($a, $b, $c)
246 | {
247 | $fixedValueComparator = new FixedValueComparator($a + $b + 1);
248 | $percentageDecider = new PercentageDecider();
249 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator);
250 | Assert::assertSame('c', $decision);
251 | }
252 | }
253 |
--------------------------------------------------------------------------------