diff --git a/package.json b/package.json
index a9a56048..c358824a 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"description": "JS.Sketcher is a parametric 2D and 3D CAD modeler written in pure javascript",
"scripts": {
- "start": "webpack-dev-server --content-base web/ --port 3000",
+ "start": "webpack-dev-server --config webpack.config.dev.js --content-base web/ --port 3000",
"pack": "webpack --config webpack.config.js --progress --profile --colors",
"build": "grunt",
"lint": "eslint web/app -c ./build/.eslintrc.json --ignore-path ./build/.eslintignore"
@@ -45,7 +45,7 @@
"diff-match-patch": "1.0.0",
"numeric": "1.2.6",
"jwerty": "0.3.2",
- "mustache-loader": "0.3.3",
+ "handlebars-loader": "1.4.0",
"jquery": "2.1.0",
"less": "2.7.1",
"libtess": "1.2.2"
diff --git a/web/app/ui/helpers/eachInMap.js b/web/app/ui/helpers/eachInMap.js
new file mode 100644
index 00000000..02fae500
--- /dev/null
+++ b/web/app/ui/helpers/eachInMap.js
@@ -0,0 +1,7 @@
+export default function( map, block ) {
+ let out = '';
+ Object.keys( map ).map(function( prop ) {
+ out += block.fn( {key: prop, value: map[ prop ]} );
+ });
+ return out;
+};
\ No newline at end of file
diff --git a/web/test/cases/arc-io.js b/web/test/cases/arc-io.js
new file mode 100644
index 00000000..1d01ccb9
--- /dev/null
+++ b/web/test/cases/arc-io.js
@@ -0,0 +1,5 @@
+export default {
+ testSaveLoad: function() {
+
+ }
+}
diff --git a/web/test/cases/segment-io.js b/web/test/cases/segment-io.js
new file mode 100644
index 00000000..a74e375e
--- /dev/null
+++ b/web/test/cases/segment-io.js
@@ -0,0 +1,7 @@
+import * as test from '../test'
+
+export default {
+ testSaveLoad: function() {
+ test.fail('Nothing works');
+ }
+}
diff --git a/web/test/menu.js b/web/test/menu.js
new file mode 100644
index 00000000..7ca5e004
--- /dev/null
+++ b/web/test/menu.js
@@ -0,0 +1,40 @@
+//import {DefaultMouseEvent} from '../app/3d/ui/utils'
+
+export class Menu {
+
+ constructor(actions) {
+ //this.mouseInfo = new DefaultMouseEvent();
+ this.actions = actions;
+ this.popup = $('#popup-menu');
+ $(document)
+ .on('mousemove', (e) => this.mouseInfo = e)
+ .on('click', (e) => this.popup.hide())
+ .on('click', '.right-click-menu', (e) => this.onShowMenu(e, $(e.currentTarget)))
+ .on('contextmenu', (e) => {
+ const target = $(e.target).closest('.right-click-menu');
+ if (target.length == 0) return true;
+ return this.onShowMenu(e, target);
+ });
+ }
+
+ onShowMenu(e, target) {
+ const popup = this.popup;
+ popup.empty();
+ const actions = target.data('menu').split(',').map(s => s.trim());
+ for (let actionId of actions) {
+ const action = this.actions[actionId];
+ if (action) {
+ popup.append($('
', {text: action.label, 'class': 'menu-item'}).click(() => {
+ popup.hide();
+ action.invoke(target)
+ }))
+ }
+ }
+ popup.show();
+ popup.offset({
+ left: e.pageX,
+ top: e.pageY
+ });
+ return false;
+ }
+}
diff --git a/web/test/runner.html b/web/test/runner.html
new file mode 100644
index 00000000..9dcd4fc7
--- /dev/null
+++ b/web/test/runner.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+ 0 run / 0 passed / 0 failures
+
+
+
+
+
+
+
diff --git a/web/test/runner.js b/web/test/runner.js
new file mode 100644
index 00000000..e52c54e5
--- /dev/null
+++ b/web/test/runner.js
@@ -0,0 +1,127 @@
+import './runner.less'
+import TestList from './tmpl/test-list.html'
+import '../app/utils/jqueryfy'
+import suites from './suites'
+import {Menu} from './menu'
+import {AssertionError} from './test'
+
+$(() => {
+ const runBtn = $('#run-button');
+ const pauseBtn = $('#pause-button');
+
+ disableBtn(pauseBtn);
+
+ runBtn.click(() => {
+ run();
+ disableBtn(runBtn);
+ enableBtn(pauseBtn);
+ });
+
+ pauseBtn.click(() => {
+ disableBtn(pauseBtn);
+ enableBtn(runBtn);
+ });
+ console.log(suites);
+ $('#test-list').html(TestList({suites}));
+ new Menu(ACTIONS);
+});
+
+
+function runSuite(name) {
+ const testCases = suites[name];
+ let success = true;
+ for (let testCase of testCases) {
+ if (!runTestCase(testCase, name + ':' +testCase.name)) {
+ success = false;
+ }
+ }
+ updateIcon($('#suite-' + name), success);
+}
+
+function runTestCase(testCase, caseId) {
+ let success = true;
+ for (let test of testCase.tests) {
+ if (!runTest(test, caseId + ':' + test.name)) {
+ success = false;
+ }
+ }
+ updateIcon($('#case-' + caseId.replace(/:/g, '-')), success);
+}
+
+function runTest(test, testId) {
+ let success = true;
+ let testDom = $('#test-' + testId.replace(/:/g, '-'));
+ testDom.find('.status').hide();
+ testDom.find('.progress').show();
+ try {
+ test();
+ } catch (e) {
+ success = false;
+ if (e instanceof AssertionError) {
+ testDom.find('.result').text(e.msg);
+ }
+ }
+ testDom.find('.progress').hide();
+ testDom.find('.status').show();
+ updateIcon(testDom, success);
+ return success;
+}
+
+function run() {
+ for (let suite of Object.keys(suites)) {
+ runSuite(suite);
+ }
+}
+
+function pause() {
+
+}
+
+
+function updateIcon(dom, success) {
+ dom.find('.status').addClass(success ? 'status-success' : 'status-fail');
+}
+
+function findTestCaseById(id) {
+ const suite = suites[id[0]];
+ return suite.filter(tc => tc.name == id[1])[0];
+}
+
+function findTestById(id) {
+ const testCase = findTestCaseById(id);
+ return testCase.tests.filter(t => t.name == id[2])[0];
+}
+
+function disableBtn(btn) {
+ btn.attr('disabled', 'disabled');
+}
+
+function enableBtn(btn) {
+ btn.removeAttr('disabled');
+}
+
+
+const ACTIONS = {
+ RunSuite: {
+ label: "Run Suite",
+ invoke: (target) => runSuite(target.data('suiteName'))
+ },
+
+ RunTestCase: {
+ label: "Run Test Case",
+ invoke: (target) => {
+ var testCaseIdStr = target.data('testCaseId');
+ const testCaseId = testCaseIdStr.split(':');
+ runTestCase(findTestCaseById(testCaseId), testCaseIdStr);
+ }
+ },
+
+ RunTest: {
+ label: "Run Test",
+ invoke: (target) => {
+ var testIdStr = target.data('testId');
+ const testId = testIdStr.split(':');
+ runTest(findTestById(testId), testIdStr)
+ }
+ }
+};
diff --git a/web/test/runner.less b/web/test/runner.less
new file mode 100644
index 00000000..360c75bc
--- /dev/null
+++ b/web/test/runner.less
@@ -0,0 +1,58 @@
+body {
+ font-family: 'Helvetica Neue Light', HelveticaNeue-Light, 'Helvetica Neue', Helvetica, sans-serif;
+ margin: 0;
+}
+
+#main-sandbox {
+ border: 3px plum solid;
+ box-sizing: border-box;
+ width: 100%;
+ height: 60%;
+}
+
+.page-row {
+ margin: 1px 1px;
+}
+
+button {
+ font-size: 14px;
+}
+
+.status {
+ color: #ccc;
+ font-size: 11px;
+}
+
+#popup-menu {
+ padding: 5px 0 5px 0;
+ border: 2px #777 solid;
+ background: #eee;
+}
+
+.menu-item {
+ padding: 2px 3px 2px 3px;
+ cursor: pointer;
+}
+
+.menu-item:hover {
+ background-color: #0074D9;
+ color: #fff;
+}
+
+.right-click-menu {
+ cursor: pointer;
+}
+
+.status-success {
+ color: green;
+}
+
+.status-fail {
+ color: red;
+}
+
+.test-node .result {
+ font-style: italic;
+ font-size: 12px;
+ color: #aaa;
+}
\ No newline at end of file
diff --git a/web/test/suites.js b/web/test/suites.js
new file mode 100644
index 00000000..7f399b4f
--- /dev/null
+++ b/web/test/suites.js
@@ -0,0 +1,27 @@
+export default {
+ SketcherIO: [
+ TestCase('segment-io'),
+ TestCase('arc-io')
+ ],
+
+ SketcherTools: [
+
+ ],
+
+ Sketcher: [
+
+ ],
+
+ ModellerOperations: [
+
+ ],
+
+};
+
+function TestCase(name) {
+ let tests = require('./cases/' + name).default;
+ tests = Object.keys(tests).filter(key => key.startsWith('test')).map(key => tests[key]);
+ return {
+ name, tests
+ }
+}
diff --git a/web/test/test.js b/web/test/test.js
new file mode 100644
index 00000000..2fac06a8
--- /dev/null
+++ b/web/test/test.js
@@ -0,0 +1,15 @@
+
+export function fail(msg, optionalMsg) {
+ optionalMsg = (optionalMsg === undefined ? '' : ' ' + optionalMsg);
+ throw new AssertionError(msg + optionalMsg);
+}
+
+export function assertEquals(expected, actual, msg) {
+ if (expected !== actual) {
+ fail('assertEquals: Expected: ' + expected + ' but was ' + actual, msg);
+ }
+}
+
+export function AssertionError(msg) {
+ this.msg = msg;
+}
\ No newline at end of file
diff --git a/web/test/tmpl/test-list.html b/web/test/tmpl/test-list.html
new file mode 100644
index 00000000..70899687
--- /dev/null
+++ b/web/test/tmpl/test-list.html
@@ -0,0 +1,26 @@
+{{#eachInMap suites}}
+
+
+
+ {{#value}}
+
+
+
+ {{#tests}}
+
+
+
+
+
+ {{/tests}}
+
+ {{/value}}
+
+{{/eachInMap}}
diff --git a/webpack.config.dev.js b/webpack.config.dev.js
new file mode 100644
index 00000000..4ead5cdd
--- /dev/null
+++ b/webpack.config.dev.js
@@ -0,0 +1,4 @@
+const config = require('./webpack.config.js');
+
+config.entry.test_runner = ['./web/test/runner'];
+module.exports = config;
diff --git a/webpack.config.js b/webpack.config.js
index 11a26911..c5b7297c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -20,7 +20,7 @@ module.exports = {
loaders: [{
test: /\.js$/,
loaders: ['babel'],
- include: path.join(__dirname, 'web/app')
+ include: [path.join(__dirname, 'web/app'), path.join(__dirname, 'web/test')]
}, {
test: /\.css$/,
loader: 'style!css'
@@ -31,7 +31,7 @@ module.exports = {
},
{
test: /\.html$/,
- loader: 'mustache'
+ loader: 'handlebars?helperDirs[]=' + __dirname + '/web/app/ui/helpers'
}]
}
};