diff --git a/scripts/system/html/js/entityProperties.js b/scripts/system/html/js/entityProperties.js
index d2fa2eeeb0..4b6329db44 100644
--- a/scripts/system/html/js/entityProperties.js
+++ b/scripts/system/html/js/entityProperties.js
@@ -66,6 +66,7 @@ function enableProperties() {
if (elLocked.checked === false) {
removeStaticUserData();
+ removeStaticMaterialData();
}
}
@@ -78,8 +79,13 @@ function disableProperties() {
}
var elLocked = document.getElementById("property-locked");
- if ($('#userdata-editor').css('display') === "block" && elLocked.checked === true) {
- showStaticUserData();
+ if (elLocked.checked === true) {
+ if ($('#userdata-editor').css('display') === "block") {
+ showStaticUserData();
+ }
+ if ($('#materialdata-editor').css('display') === "block") {
+ showStaticMaterialData();
+ }
}
}
@@ -356,15 +362,139 @@ function userDataChanger(groupName, keyName, values, userDataElement, defaultVal
multiDataUpdater(groupName, val, userDataElement, def);
}
+function setMaterialDataFromEditor(noUpdate) {
+ var json = null;
+ try {
+ json = materialEditor.get();
+ } catch (e) {
+ alert('Invalid JSON code - look for red X in your code ', +e);
+ }
+ if (json === null) {
+ return;
+ } else {
+ var text = materialEditor.getText();
+ if (noUpdate === true) {
+ EventBridge.emitWebEvent(
+ JSON.stringify({
+ id: lastEntityID,
+ type: "saveMaterialData",
+ properties: {
+ materialData: text
+ }
+ })
+ );
+ return;
+ } else {
+ updateProperty('materialData', text);
+ }
+ }
+}
+
function setTextareaScrolling(element) {
var isScrolling = element.scrollHeight > element.offsetHeight;
element.setAttribute("scrolling", isScrolling ? "true" : "false");
}
+var materialEditor = null;
+
+function createJSONMaterialEditor() {
+ var container = document.getElementById("materialdata-editor");
+ var options = {
+ search: false,
+ mode: 'tree',
+ modes: ['code', 'tree'],
+ name: 'materialData',
+ onModeChange: function() {
+ $('.jsoneditor-poweredBy').remove();
+ },
+ onError: function(e) {
+ alert('JSON editor:' + e);
+ },
+ onChange: function() {
+ var currentJSONString = materialEditor.getText();
+
+ if (currentJSONString === '{"":""}') {
+ return;
+ }
+ $('#materialdata-save').attr('disabled', false);
+
+
+ }
+ };
+ materialEditor = new JSONEditor(container, options);
+}
+
+function hideNewJSONMaterialEditorButton() {
+ $('#materialdata-new-editor').hide();
+}
+
+function showSaveMaterialDataButton() {
+ $('#materialdata-save').show();
+}
+
+function hideSaveMaterialDataButton() {
+ $('#materialdata-save').hide();
+}
+
+function showNewJSONMaterialEditorButton() {
+ $('#materialdata-new-editor').show();
+}
+
+function showMaterialDataTextArea() {
+ $('#property-material-data').show();
+}
+
+function hideMaterialDataTextArea() {
+ $('#property-material-data').hide();
+}
+
+function showStaticMaterialData() {
+ if (materialEditor !== null) {
+ $('#static-materialdata').show();
+ $('#static-materialdata').css('height', $('#materialdata-editor').height());
+ $('#static-materialdata').text(materialEditor.getText());
+ }
+}
+
+function removeStaticMaterialData() {
+ $('#static-materialdata').hide();
+}
+
+function setMaterialEditorJSON(json) {
+ materialEditor.set(json);
+ if (materialEditor.hasOwnProperty('expandAll')) {
+ materialEditor.expandAll();
+ }
+}
+
+function getMaterialEditorJSON() {
+ return materialEditor.get();
+}
+
+function deleteJSONMaterialEditor() {
+ if (materialEditor !== null) {
+ materialEditor.destroy();
+ materialEditor = null;
+ }
+}
+
+var savedMaterialJSONTimer = null;
+
+function saveJSONMaterialData(noUpdate) {
+ setMaterialDataFromEditor(noUpdate);
+ $('#materialdata-saved').show();
+ $('#materialdata-save').attr('disabled', true);
+ if (savedMaterialJSONTimer !== null) {
+ clearTimeout(savedMaterialJSONTimer);
+ }
+ savedMaterialJSONTimer = setTimeout(function() {
+ $('#materialdata-saved').hide();
+
+ }, EDITOR_TIMEOUT_DURATION);
+}
+
var editor = null;
-var editorTimeout = null;
-var lastJSONString = null;
function createJSONEditor() {
var container = document.getElementById("userdata-editor");
@@ -395,11 +525,6 @@ function createJSONEditor() {
function hideNewJSONEditorButton() {
$('#userdata-new-editor').hide();
-
-}
-
-function hideClearUserDataButton() {
- $('#userdata-clear').hide();
}
function showSaveUserDataButton() {
@@ -408,17 +533,10 @@ function showSaveUserDataButton() {
function hideSaveUserDataButton() {
$('#userdata-save').hide();
-
}
function showNewJSONEditorButton() {
$('#userdata-new-editor').show();
-
-}
-
-function showClearUserDataButton() {
- $('#userdata-clear').show();
-
}
function showUserDataTextArea() {
@@ -446,7 +564,6 @@ function setEditorJSON(json) {
if (editor.hasOwnProperty('expandAll')) {
editor.expandAll();
}
-
}
function getEditorJSON() {
@@ -484,12 +601,15 @@ function bindAllNonJSONEditorElements() {
// TODO FIXME: (JSHint) Functions declared within loops referencing
// an outer scoped variable may lead to confusing semantics.
field.on('focus', function(e) {
- if (e.target.id === "userdata-new-editor" || e.target.id === "userdata-clear") {
+ if (e.target.id === "userdata-new-editor" || e.target.id === "userdata-clear" || e.target.id === "materialdata-new-editor" || e.target.id === "materialdata-clear") {
return;
} else {
if ($('#userdata-editor').css('height') !== "0px") {
saveJSONUserData(true);
}
+ if ($('#materialdata-editor').css('height') !== "0px") {
+ saveJSONMaterialData(true);
+ }
}
});
}
@@ -652,6 +772,10 @@ function loaded() {
var elMaterialMappingScaleX = document.getElementById("property-material-mapping-scale-x");
var elMaterialMappingScaleY = document.getElementById("property-material-mapping-scale-y");
var elMaterialMappingRot = document.getElementById("property-material-mapping-rot");
+ var elMaterialData = document.getElementById("property-material-data");
+ var elClearMaterialData = document.getElementById("materialdata-clear");
+ var elSaveMaterialData = document.getElementById("materialdata-save");
+ var elNewJSONMaterialEditor = document.getElementById('materialdata-new-editor');
var elImageURL = document.getElementById("property-image-url");
@@ -772,9 +896,15 @@ function loaded() {
} else if (data.type === "update") {
if (!data.selections || data.selections.length === 0) {
- if (editor !== null && lastEntityID !== null) {
- saveJSONUserData(true);
- deleteJSONEditor();
+ if (lastEntityID !== null) {
+ if (editor !== null) {
+ saveJSONUserData(true);
+ deleteJSONEditor();
+ }
+ if (materialEditor !== null) {
+ saveJSONMaterialData(true);
+ deleteJSONMaterialEditor();
+ }
}
elTypeIcon.style.display = "none";
elType.innerHTML = "
No selection";
@@ -783,6 +913,7 @@ function loaded() {
disableProperties();
} else if (data.selections && data.selections.length > 1) {
deleteJSONEditor();
+ deleteJSONMaterialEditor();
var selections = data.selections;
var ids = [];
@@ -815,8 +946,13 @@ function loaded() {
} else {
properties = data.selections[0].properties;
- if (lastEntityID !== '"' + properties.id + '"' && lastEntityID !== null && editor !== null) {
- saveJSONUserData(true);
+ if (lastEntityID !== '"' + properties.id + '"' && lastEntityID !== null) {
+ if (editor !== null) {
+ saveJSONUserData(true);
+ }
+ if (materialEditor !== null) {
+ saveJSONMaterialData(true);
+ }
}
var doSelectElement = lastEntityID === '"' + properties.id + '"';
@@ -993,6 +1129,28 @@ function loaded() {
hideNewJSONEditorButton();
}
+ var materialJson = null;
+ try {
+ materialJson = JSON.parse(properties.materialData);
+ } catch (e) {
+ // normal text
+ deleteJSONMaterialEditor();
+ elMaterialData.value = properties.materialData;
+ showMaterialDataTextArea();
+ showNewJSONMaterialEditorButton();
+ hideSaveMaterialDataButton();
+ }
+ if (materialJson !== null) {
+ if (materialEditor === null) {
+ createJSONMaterialEditor();
+ }
+
+ setMaterialEditorJSON(materialJson);
+ showSaveMaterialDataButton();
+ hideMaterialDataTextArea();
+ hideNewJSONMaterialEditorButton();
+ }
+
elHyperlinkHref.value = properties.href;
elDescription.value = properties.description;
@@ -1200,6 +1358,7 @@ function loaded() {
} else {
enableProperties();
elSaveUserData.disabled = true;
+ elSaveMaterialData.disabled = true;
}
var activeElement = document.activeElement;
@@ -1384,6 +1543,31 @@ function loaded() {
showSaveUserDataButton();
});
+ elClearMaterialData.addEventListener("click", function() {
+ deleteJSONMaterialEditor();
+ elMaterialData.value = "";
+ showMaterialDataTextArea();
+ showNewJSONMaterialEditorButton();
+ hideSaveMaterialDataButton();
+ updateProperty('materialData', elMaterialData.value);
+ });
+
+ elSaveMaterialData.addEventListener("click", function() {
+ saveJSONMaterialData(true);
+ });
+
+ elMaterialData.addEventListener('change', createEmitTextPropertyUpdateFunction('materialData'));
+
+ elNewJSONMaterialEditor.addEventListener('click', function() {
+ deleteJSONMaterialEditor();
+ createJSONMaterialEditor();
+ var data = {};
+ setMaterialEditorJSON(data);
+ hideMaterialDataTextArea();
+ hideNewJSONMaterialEditorButton();
+ showSaveMaterialDataButton();
+ });
+
var colorChangeFunction = createEmitColorPropertyUpdateFunction(
'color', elColorRed, elColorGreen, elColorBlue);
elColorRed.addEventListener('change', colorChangeFunction);
diff --git a/scripts/tutorials/entity_scripts/sit.js b/scripts/tutorials/entity_scripts/sit.js
index 70456ea493..9c994ed2ea 100644
--- a/scripts/tutorials/entity_scripts/sit.js
+++ b/scripts/tutorials/entity_scripts/sit.js
@@ -12,9 +12,9 @@
Script.include("/~/system/libraries/utils.js");
if (!String.prototype.startsWith) {
String.prototype.startsWith = function(searchString, position){
- position = position || 0;
- return this.substr(position, searchString.length) === searchString;
- };
+ position = position || 0;
+ return this.substr(position, searchString.length) === searchString;
+ };
}
var SETTING_KEY = "com.highfidelity.avatar.isSitting";
@@ -122,20 +122,10 @@
this.rolesToOverride = function() {
return MyAvatar.getAnimationRoles().filter(function(role) {
- return !(role.startsWith("right") || role.startsWith("left"));
+ return !(role.startsWith("right") || role.startsWith("left"));
});
}
- // Handler for user changing the avatar model while sitting. There's currently an issue with changing avatar models while override role animations are applied,
- // so to avoid that problem, re-apply the role overrides once the model has finished changing.
- this.modelURLChangeFinished = function () {
- print("Sitter's model has FINISHED changing. Reapply anim role overrides.");
- var roles = this.rolesToOverride();
- for (i in roles) {
- MyAvatar.overrideRoleAnimation(roles[i], ANIMATION_URL, ANIMATION_FPS, true, ANIMATION_FIRST_FRAME, ANIMATION_LAST_FRAME);
- }
- }
-
this.sitDown = function() {
if (this.checkSeatForAvatar()) {
print("Someone is already sitting in that chair.");
@@ -155,11 +145,11 @@
MyAvatar.characterControllerEnabled = false;
MyAvatar.hmdLeanRecenterEnabled = false;
var roles = this.rolesToOverride();
- for (i in roles) {
+ for (var i = 0; i < roles.length; i++) {
MyAvatar.overrideRoleAnimation(roles[i], ANIMATION_URL, ANIMATION_FPS, true, ANIMATION_FIRST_FRAME, ANIMATION_LAST_FRAME);
}
- for (var i in OVERRIDEN_DRIVE_KEYS) {
+ for (i = 0; i < OVERRIDEN_DRIVE_KEYS.length; i++) {
MyAvatar.disableDriveKey(OVERRIDEN_DRIVE_KEYS[i]);
}
@@ -174,14 +164,12 @@
return { headType: 0 };
}, ["headType"]);
Script.update.connect(this, this.update);
- MyAvatar.onLoadComplete.connect(this, this.modelURLChangeFinished);
}
this.standUp = function() {
print("Standing up (" + this.entityID + ")");
MyAvatar.removeAnimationStateHandler(this.animStateHandlerID);
Script.update.disconnect(this, this.update);
- MyAvatar.onLoadComplete.disconnect(this, this.modelURLChangeFinished);
if (MyAvatar.sessionUUID === this.getSeatUser()) {
this.setSeatUser(null);
@@ -190,12 +178,12 @@
if (Settings.getValue(SETTING_KEY) === this.entityID) {
Settings.setValue(SETTING_KEY, "");
- for (var i in OVERRIDEN_DRIVE_KEYS) {
+ for (var i = 0; i < OVERRIDEN_DRIVE_KEYS.length; i++) {
MyAvatar.enableDriveKey(OVERRIDEN_DRIVE_KEYS[i]);
}
var roles = this.rolesToOverride();
- for (i in roles) {
+ for (i = 0; i < roles.length; i++) {
MyAvatar.restoreRoleAnimation(roles[i]);
}
MyAvatar.characterControllerEnabled = true;
@@ -272,7 +260,7 @@
// Check if a drive key is pressed
var hasActiveDriveKey = false;
for (var i in OVERRIDEN_DRIVE_KEYS) {
- if (MyAvatar.getRawDriveKey(OVERRIDEN_DRIVE_KEYS[i]) != 0.0) {
+ if (MyAvatar.getRawDriveKey(OVERRIDEN_DRIVE_KEYS[i]) !== 0.0) {
hasActiveDriveKey = true;
break;
}
@@ -343,7 +331,7 @@
}
this.cleanupOverlay();
}
-
+
this.clickDownOnEntity = function (id, event) {
if (isInEditMode()) {
return;
@@ -352,4 +340,4 @@
this.sitDown();
}
}
-});
+});
\ No newline at end of file
diff --git a/tools/auto-tester/src/Test.cpp b/tools/auto-tester/src/Test.cpp
index 5d8b9115b8..99f9025fdd 100644
--- a/tools/auto-tester/src/Test.cpp
+++ b/tools/auto-tester/src/Test.cpp
@@ -304,7 +304,7 @@ void Test::createRecursiveScript() {
}
// This method creates a `testRecursive.js` script in every sub-folder.
-void Test::createRecursiveScriptsRecursively() {
+void Test::createAllRecursiveScripts() {
// Select folder to start recursing from
QString topLevelDirectory = QFileDialog::getExistingDirectory(nullptr, "Please select the root folder for the recursive scripts", ".", QFileDialog::ShowDirsOnly);
if (topLevelDirectory == "") {
@@ -559,6 +559,44 @@ void Test::createMDFile() {
return;
}
+ createMDFile(testDirectory);
+}
+
+void Test::createAllMDFiles() {
+ // Select folder to start recursing from
+ QString topLevelDirectory = QFileDialog::getExistingDirectory(nullptr, "Please select the root folder for the MD files", ".", QFileDialog::ShowDirsOnly);
+ if (topLevelDirectory == "") {
+ return;
+ }
+
+ // First test if top-level folder has a test.js file
+ const QString testPathname{ topLevelDirectory + "/" + TEST_FILENAME };
+ QFileInfo fileInfo(testPathname);
+ if (fileInfo.exists()) {
+ createMDFile(topLevelDirectory);
+ }
+
+ QDirIterator it(topLevelDirectory.toStdString().c_str(), QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ QString directory = it.next();
+
+ // Only process directories
+ QDir dir;
+ if (!isAValidDirectory(directory)) {
+ continue;
+ }
+
+ const QString testPathname{ directory + "/" + TEST_FILENAME };
+ QFileInfo fileInfo(testPathname);
+ if (fileInfo.exists()) {
+ createMDFile(directory);
+ }
+ }
+
+ messageBox.information(0, "Success", "MD files have been created");
+}
+
+void Test::createMDFile(QString testDirectory) {
// Verify folder contains test.js file
QString testFileName(testDirectory + "/" + TEST_FILENAME);
QFileInfo testFileInfo(testFileName);
@@ -639,8 +677,8 @@ void Test::createMDFile() {
int snapShotIndex { 0 };
for (size_t i = 0; i < testScriptLines.stepList.size(); ++i) {
- stream << "### Step " << QString::number(i) << "\n";
- stream << "- " << testScriptLines.stepList[i + 1]->text << "\n";
+ stream << "### Step " << QString::number(i + 1) << "\n";
+ stream << "- " << testScriptLines.stepList[i]->text << "\n";
if (testScriptLines.stepList[i]->takeSnapshot) {
stream << "- .rightJustified(5, '0') << ".png)\n";
++snapShotIndex;
@@ -650,6 +688,77 @@ void Test::createMDFile() {
mdFile.close();
}
+void Test::createTestsOutline() {
+ QString testsRootDirectory = QFileDialog::getExistingDirectory(nullptr, "Please select the tests root folder", ".", QFileDialog::ShowDirsOnly);
+ if (testsRootDirectory == "") {
+ return;
+ }
+
+ const QString testsOutlineFilename { "testsOutline.md" };
+ QString mdFilename(testsRootDirectory + "/" + testsOutlineFilename);
+ QFile mdFile(mdFilename);
+ if (!mdFile.open(QIODevice::WriteOnly)) {
+ messageBox.critical(0, "Internal error: " + QString(__FILE__) + ":" + QString::number(__LINE__), "Failed to create file " + mdFilename);
+ exit(-1);
+ }
+
+ QTextStream stream(&mdFile);
+
+ //Test title
+ stream << "# Outline of all tests\n";
+ stream << "Directories with an appended (*) have an automatic test\n\n";
+
+ // We need to know our current depth, as this isn't given by QDirIterator
+ int rootDepth { testsRootDirectory.count('/') };
+
+ // Each test is shown as the folder name linking to the matching GitHub URL, and the path to the associated test.md file
+ QDirIterator it(testsRootDirectory.toStdString().c_str(), QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ QString directory = it.next();
+
+ // Only process directories
+ QDir dir;
+ if (!isAValidDirectory(directory)) {
+ continue;
+ }
+
+ // Ignore the utils directory
+ if (directory.right(5) == "utils") {
+ continue;
+ }
+
+ // The prefix is the MarkDown prefix needed for correct indentation
+ // It consists of 2 spaces for each level of indentation, folled by a dash sign
+ int currentDepth = directory.count('/') - rootDepth;
+ QString prefix = QString(" ").repeated(2 * currentDepth - 1) + " - ";
+
+ // The directory name appears after the last slash (we are assured there is at least 1).
+ QString directoryName = directory.right(directory.length() - directory.lastIndexOf("/") - 1);
+
+ // autoTester is run on a clone of the repository. We use relative paths, so we can use both local disk and GitHub
+ // For a test in "D:/GitHub/hifi_tests/tests/content/entity/zone/ambientLightInheritance" the
+ // GitHub URL is "./content/entity/zone/ambientLightInheritance?raw=true"
+ QString partialPath = directory.right(directory.length() - (directory.lastIndexOf("/tests/") + QString("/tests").length() + 1));
+ QString url = "./" + partialPath;
+
+ stream << prefix << "[" << directoryName << "](" << url << "?raw=true" << ")";
+ QFileInfo fileInfo1(directory + "/test.md");
+ if (fileInfo1.exists()) {
+ stream << " [(test description)](" << url << "/test.md)";
+ }
+
+ QFileInfo fileInfo2(directory + "/" + TEST_FILENAME);
+ if (fileInfo2.exists()) {
+ stream << " (*)";
+ }
+ stream << "\n";
+ }
+
+ mdFile.close();
+
+ messageBox.information(0, "Success", "Test outline file " + testsOutlineFilename + " has been created");
+}
+
void Test::copyJPGtoPNG(QString sourceJPGFullFilename, QString destinationPNGFullFilename) {
QFile::remove(destinationPNGFullFilename);
diff --git a/tools/auto-tester/src/Test.h b/tools/auto-tester/src/Test.h
index 3d04b00df9..e69459fef2 100644
--- a/tools/auto-tester/src/Test.h
+++ b/tools/auto-tester/src/Test.h
@@ -45,11 +45,15 @@ public:
void finishTestsEvaluation(bool interactiveMode, QProgressBar* progressBar);
void createRecursiveScript();
- void createRecursiveScriptsRecursively();
+ void createAllRecursiveScripts();
void createRecursiveScript(QString topLevelDirectory, bool interactiveMode);
void createTest();
void createMDFile();
+ void createAllMDFiles();
+ void createMDFile(QString topLevelDirectory);
+
+ void createTestsOutline();
bool compareImageLists(bool isInteractiveMode, QProgressBar* progressBar);
diff --git a/tools/auto-tester/src/ui/AutoTester.cpp b/tools/auto-tester/src/ui/AutoTester.cpp
index 9153365184..21acfe9569 100644
--- a/tools/auto-tester/src/ui/AutoTester.cpp
+++ b/tools/auto-tester/src/ui/AutoTester.cpp
@@ -28,8 +28,8 @@ void AutoTester::on_createRecursiveScriptButton_clicked() {
test->createRecursiveScript();
}
-void AutoTester::on_createRecursiveScriptsRecursivelyButton_clicked() {
- test->createRecursiveScriptsRecursively();
+void AutoTester::on_createAllRecursiveScriptsButton_clicked() {
+ test->createAllRecursiveScripts();
}
void AutoTester::on_createTestButton_clicked() {
@@ -37,7 +37,15 @@ void AutoTester::on_createTestButton_clicked() {
}
void AutoTester::on_createMDFileButton_clicked() {
- test->createMDFile();
+ test->createMDFile();
+}
+
+void AutoTester::on_createAllMDFilesButton_clicked() {
+ test->createAllMDFiles();
+}
+
+void AutoTester::on_createTestsOutlineButton_clicked() {
+ test->createTestsOutline();
}
void AutoTester::on_closeButton_clicked() {
diff --git a/tools/auto-tester/src/ui/AutoTester.h b/tools/auto-tester/src/ui/AutoTester.h
index 82ff3780e3..1788e97177 100644
--- a/tools/auto-tester/src/ui/AutoTester.h
+++ b/tools/auto-tester/src/ui/AutoTester.h
@@ -28,10 +28,12 @@ public:
private slots:
void on_evaluateTestsButton_clicked();
void on_createRecursiveScriptButton_clicked();
- void on_createRecursiveScriptsRecursivelyButton_clicked();
+ void on_createAllRecursiveScriptsButton_clicked();
void on_createTestButton_clicked();
- void on_createMDFileButton_clicked();
- void on_closeButton_clicked();
+ void on_createMDFileButton_clicked();
+ void on_createAllMDFilesButton_clicked();
+ void on_createTestsOutlineButton_clicked();
+ void on_closeButton_clicked();
void saveImage(int index);
diff --git a/tools/auto-tester/src/ui/AutoTester.ui b/tools/auto-tester/src/ui/AutoTester.ui
index 600de283ad..2eb1314481 100644
--- a/tools/auto-tester/src/ui/AutoTester.ui
+++ b/tools/auto-tester/src/ui/AutoTester.ui
@@ -17,8 +17,8 @@
- 20
- 420
+ 360
+ 400
220
40
@@ -44,7 +44,7 @@
20
- 255
+ 285
220
40
@@ -57,7 +57,7 @@
360
- 75
+ 35
220
40
@@ -70,7 +70,7 @@
23
- 220
+ 250
131
20
@@ -86,7 +86,7 @@
20
- 310
+ 340
255
23
@@ -99,20 +99,20 @@
360
- 140
+ 100
220
40
- Create Recursive Scripts Recursively
+ Create all Recursive Scripts
20
- 90
+ 80
220
40
@@ -121,6 +121,32 @@
Create MD file
+
+
+
+ 20
+ 130
+ 220
+ 40
+
+
+
+ Create all MD files
+
+
+
+
+
+ 20
+ 180
+ 220
+ 40
+
+
+
+ Create Tests Outline
+
+