diff --git a/README.md b/README.md
index 0a6aadc9c7..f038a5ab64 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,17 @@ For each challenge section, there is a JSON file (fields documented below) conta
|---|---|
| `npm run test-challenges` | run all challenge tests (for each challenge JSON file, run all `tests` against all `solutions`) |
| `npm run test` | run all JS tests in the system, including client, server, lint and challenge tests |
-| `node seed` | parses all the challenge JSON files and saves them into MongoDB (code is inside [index.js](index.js)) |
+| `npm run seed`
(or `node seed`) | parses all the challenge JSON files and saves them into MongoDB (code is inside [index.js](index.js)) |
| `npm run commit` | interactive tool to help you build a good commit message |
+| `npm run unpack` | extract challenges from `seed/challenges` into `unpacked` subdirectory, one HTML page per challenge |
+| `npm run repack` | repack challenges from `unpacked` subdirectory into `seed/challenges` |
+
+### unpack and repack
+
+`npm run unpack` extracts challenges into separate files for easier viewing and editing. The files are `.gitignore`d and will *not* be checked in, and all mongo seed importing will keep using the existing system; this is essentially a tool for editing `challenge.json` files. These HTML files are self-contained and run their own tests -- open a browser JS console to see the test results.
+
+`npm run repack` gathers up the unpacked/edited HTML files into challenge-block JSON files. Use `git diff` to see the changes
+
## Links
@@ -24,7 +33,7 @@ For each challenge section, there is a JSON file (fields documented below) conta
## Challenge Template
-```json
+```
{
"id": "unique identifier (alphanumerical, mongodb id)",
"title": "Challenge Title",
diff --git a/addAssertsToTapTest.js b/addAssertsToTapTest.js
new file mode 100644
index 0000000000..58fb62a3e1
--- /dev/null
+++ b/addAssertsToTapTest.js
@@ -0,0 +1,52 @@
+let _ = require('lodash');
+
+function createIsAssert(tapTest, isThing) {
+ const { assert } = tapTest;
+ return function() {
+ const args = [...arguments];
+ args[0] = isThing(args[0]);
+ assert.apply(tapTest, args);
+ };
+}
+
+function addAssertsToTapTest(tapTest) {
+ const assert = tapTest.assert;
+
+ assert.isArray = createIsAssert(tapTest, _.isArray);
+ assert.isBoolean = createIsAssert(tapTest, _.isBoolean);
+ assert.isString = createIsAssert(tapTest, _.isString);
+ assert.isNumber = createIsAssert(tapTest, _.isNumber);
+ assert.isUndefined = createIsAssert(tapTest, _.isUndefined);
+
+ assert.deepEqual = tapTest.deepEqual;
+ assert.equal = tapTest.equal;
+ assert.strictEqual = tapTest.equal;
+ assert.sameMembers = function sameMembers() {
+ const [ first, second, ...args] = arguments;
+ assert.apply(
+ tapTest,
+ [
+ _.difference(first, second).length === 0 &&
+ _.difference(second, first).length === 0
+ ].concat(args)
+ );
+ };
+ assert.includeMembers = function includeMembers() {
+ const [ first, second, ...args] = arguments;
+ assert.apply(tapTest,
+ [
+ _.difference(second, first).length === 0
+ ].concat(args));
+ };
+ assert.match = function match() {
+ const [value, regex, ...args] = arguments;
+ assert.apply(tapTest,
+ [
+ regex.test(value)
+ ].concat(args));
+ };
+
+ return assert;
+}
+
+module.exports = addAssertsToTapTest;
diff --git a/getChallenges.js b/getChallenges.js
index 7ff5f6b6d5..a8a413fc99 100644
--- a/getChallenges.js
+++ b/getChallenges.js
@@ -4,64 +4,54 @@ const fs = require('fs');
const path = require('path');
const hiddenFile = /(^(\.|\/\.))|(.md$)/g;
+
function getFilesFor(dir) {
- return fs.readdirSync(path.join(__dirname, '/' + dir))
+ let targetDir = path.join(__dirname, dir);
+ return fs.readdirSync(targetDir)
.filter(file => !hiddenFile.test(file))
.map(function(file) {
let superBlock;
- if (fs.statSync(path.join(__dirname, dir + '/' + file)).isFile()) {
- return { file: file };
+ if (fs.statSync(path.join(targetDir, file)).isFile()) {
+ return {file: file};
}
superBlock = file;
- return getFilesFor(dir + '/' + superBlock)
+ return getFilesFor(path.join(dir, superBlock))
.map(function(data) {
return {
- file: superBlock + '/' + data.file,
+ file: path.join(superBlock, data.file),
superBlock: superBlock
};
});
})
- .reduce(function(files, file) {
- if (!Array.isArray(file)) {
- files.push(file);
- return files;
- }
- return files.concat(file);
+ .reduce(function(files, entry) {
+ return files.concat(entry);
}, []);
}
-function getSupOrder(filePath) {
- const order = parseInt((filePath || '').split('-')[0], 10);
- // check for NaN
- if (order !== order) {
- return 0;
+function superblockInfo(filePath) {
+ let parts = (filePath || '').split('-');
+ let order = parseInt(parts[0], 10);
+ if (isNaN(order)) {
+ return {order: 0, name: filePath};
+ } else {
+ return {
+ order: order,
+ name: parts.splice(1).join('-')
+ };
}
- return order;
}
-function getSupName(filePath) {
- const order = parseInt((filePath || '').split('-')[0], 10);
- // check for NaN
- if (order !== order) {
- return filePath;
- }
-
- return (filePath || '').split('-').splice(1).join('-');
-}
-
-module.exports = function getChallenges() {
- try {
- return getFilesFor('challenges')
- .map(function(data) {
- const challengeSpec = require('./challenges/' + data.file);
- challengeSpec.fileName = data.file;
- challengeSpec.superBlock = getSupName(data.superBlock);
- challengeSpec.superOrder = getSupOrder(data.superBlock);
-
- return challengeSpec;
- });
- } catch (e) {
- console.error('error: ', e);
- return [];
+module.exports = function getChallenges(challengesDir) {
+ if (!challengesDir) {
+ challengesDir = 'challenges';
}
+ return getFilesFor(challengesDir)
+ .map(function(data) {
+ const challengeSpec = require('./' + challengesDir + '/' + data.file);
+ let superInfo = superblockInfo(data.superBlock);
+ challengeSpec.fileName = data.file;
+ challengeSpec.superBlock = superInfo.name;
+ challengeSpec.superOrder = superInfo.order;
+ return challengeSpec;
+ });
};
diff --git a/mongoIds.js b/mongoIds.js
new file mode 100644
index 0000000000..034a0b48cf
--- /dev/null
+++ b/mongoIds.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+import { isMongoId } from 'validator';
+
+class MongoIds {
+ constructor() {
+ this.knownIds = [];
+ }
+ check(id, title) {
+ if (!isMongoId(id)) {
+ throw new Error(`Expected a valid ObjectId for ${title}, but got ${id}`);
+ }
+ const idIndex = _.findIndex(this.knownIds, existing => id === existing);
+ if (idIndex !== -1) {
+ throw new Error(`
+ All challenges must have a unique id.
+
+ The id for ${title} is already assigned
+ `);
+ }
+ this.knownIds = [ ...this.knownIds, id ];
+ }
+}
+
+export default MongoIds;
diff --git a/normalize-seed-files.js b/normalize-seed-files.js
index 4be5fec3e6..a777f3ca72 100644
--- a/normalize-seed-files.js
+++ b/normalize-seed-files.js
@@ -52,16 +52,16 @@ function createNewTranslations(challenge) {
newTranslation = {};
newTranslation[matches[1]] = challenge[oldKey];
translations[tag] = translations[tag] ?
- Object.assign({}, translations[tag], newTranslation) :
- Object.assign({}, newTranslation);
+ ({...translations[tag], ...newTranslation}) :
+ ({...newTranslation});
return translations;
}
matches = oldKey.match(oldNameRegex);
tag = normalizeLangTag(matches[1]);
newTranslation = { title: challenge[oldKey] };
translations[tag] = translations[tag] ?
- Object.assign({}, translations[tag], newTranslation) :
- Object.assign({}, newTranslation);
+ ({...translations[tag], ...newTranslation}) :
+ ({...newTranslation});
return translations;
}, {});
}
@@ -71,11 +71,10 @@ function normalizeChallenge(challenge) {
challenge.translations = challenge.translations || {};
var hasOldTranslations = keys.some(hasOldTranslation);
if (hasOldTranslations) {
- challenge.translations = Object.assign(
- {},
- challenge.translations,
- createNewTranslations(challenge)
- );
+ challenge.translations = ({
+ ...challenge.translations,
+ ...createNewTranslations(challenge)
+ });
}
challenge.translations = sortTranslationsKeys(challenge.translations);
// remove old translations from the top level
diff --git a/repack.js b/repack.js
new file mode 100644
index 0000000000..9328b3e611
--- /dev/null
+++ b/repack.js
@@ -0,0 +1,76 @@
+/* eslint-disable no-eval, no-process-exit */
+import fs from 'fs-extra';
+import path from 'path';
+import {UnpackedChallenge, ChallengeFile} from './unpackedChallenge';
+
+const jsdiff = require('diff');
+
+// Repack all challenges from all
+// seed/unpacked/00-foo/bar/000-id.html files
+// into
+// seed/challenges/00-foo/bar.json files
+
+let unpackedRoot = path.join(__dirname, 'unpacked');
+let seedChallengesRoot = path.join(__dirname, 'challenges');
+
+function directoriesIn(parentDir) {
+ return fs.readdirSync(parentDir)
+ .filter(entry => fs.statSync(path.join(parentDir, entry)).isDirectory());
+}
+
+let superBlocks = directoriesIn(unpackedRoot);
+console.log(superBlocks);
+
+function diffFiles(originalFilePath, changedFilePath) {
+ // todo: async
+ console.log(`diffing ${originalFilePath} and ${changedFilePath}`);
+ let original = fs.readFileSync(originalFilePath).toString();
+ let repacked = fs.readFileSync(changedFilePath).toString();
+
+ let changes = jsdiff.diffLines(original, repacked, { newlineIsToken: true });
+ changes.forEach((change) => {
+ if (change.added || change.removed) {
+ console.log(JSON.stringify(change, null, 2));
+ }
+ });
+ console.log('');
+}
+
+superBlocks.forEach(superBlock => {
+ let superBlockPath = path.join(unpackedRoot, superBlock);
+ let blocks = directoriesIn(superBlockPath);
+ blocks.forEach(blockName => {
+ let blockPath = path.join(superBlockPath, blockName);
+ let blockFilePath = path.join(blockPath, blockName + '.json');
+ let block = require(blockFilePath);
+ let index = 0;
+ block.challenges.forEach(challengeJson => {
+ let unpackedChallenge =
+ new UnpackedChallenge(blockPath, challengeJson, index);
+ let unpackedFile = unpackedChallenge.challengeFile();
+ let chunks = unpackedFile.readChunks();
+
+ Object.assign(block.challenges[ index ], chunks);
+
+ index += 1;
+ });
+
+ let outputFilePath =
+ path.join(seedChallengesRoot, superBlock, blockName + '.json');
+ // todo: async
+ fs.writeFileSync(outputFilePath, JSON.stringify(block, null, 2));
+
+ // todo: make this a command-line option instead
+ let doDiff = false;
+ if (doDiff) {
+ diffFiles(blockFilePath, outputFilePath);
+ }
+
+ });
+
+});
+
+// let challenges = getChallenges();
+// challenges.forEach(challengeBlock => {
+// console.log()
+// });
diff --git a/test-challenges.js b/test-challenges.js
index 1f17a2f768..a7cdf6b11b 100644
--- a/test-challenges.js
+++ b/test-challenges.js
@@ -1,76 +1,106 @@
-/* eslint-disable no-eval, no-process-exit */
-import _ from 'lodash';
-import { Observable } from 'rx';
+/* eslint-disable no-eval, no-process-exit, no-unused-vars */
+
+import {Observable} from 'rx';
import tape from 'tape';
-import { isMongoId } from 'validator';
import getChallenges from './getChallenges';
import { modern } from '../common/app/utils/challengeTypes';
+import MongoIds from './mongoIds';
+import addAssertsToTapTest from './addAssertsToTapTest';
-const notMongoId = id => !isMongoId(id);
+let mongoIds = new MongoIds();
-let existingIds = [];
+function evaluateTest(solution, assert,
+ react, redux, reactRedux,
+ head, tail,
+ test, tapTest) {
+
+ let code = solution;
+
+ /* NOTE: Provide dependencies for React/Redux challenges
+ * and configure testing environment
+ */
+ let React,
+ ReactDOM,
+ Redux,
+ ReduxThunk,
+ ReactRedux,
+ Enzyme,
+ document;
+
+ // Fake Deep Equal dependency
+ const DeepEqual = (a, b) =>
+ JSON.stringify(a) === JSON.stringify(b);
+
+ // Hardcode Deep Freeze dependency
+ const DeepFreeze = (o) => {
+ Object.freeze(o);
+ Object.getOwnPropertyNames(o).forEach(function(prop) {
+ if (o.hasOwnProperty(prop)
+ && o[ prop ] !== null
+ && (
+ typeof o[ prop ] === 'object' ||
+ typeof o[ prop ] === 'function'
+ )
+ && !Object.isFrozen(o[ prop ])) {
+ DeepFreeze(o[ prop ]);
+ }
+ });
+ return o;
+ };
+
+ if (react || redux || reactRedux) {
+ // Provide dependencies, just provide all of them
+ React = require('react');
+ ReactDOM = require('react-dom');
+ Redux = require('redux');
+ ReduxThunk = require('redux-thunk');
+ ReactRedux = require('react-redux');
+ Enzyme = require('enzyme');
+ const Adapter15 = require('enzyme-adapter-react-15');
+ Enzyme.configure({ adapter: new Adapter15() });
+
+ /* Transpile ALL the code
+ * (we may use JSX in head or tail or tests, too): */
+ const transform = require('babel-standalone').transform;
+ const options = { presets: [ 'es2015', 'react' ] };
+
+ head = transform(head, options).code;
+ solution = transform(solution, options).code;
+ tail = transform(tail, options).code;
+ test = transform(test, options).code;
+
+ const { JSDOM } = require('jsdom');
+ // Mock DOM document for ReactDOM.render method
+ const jsdom = new JSDOM(`
+
+
This is the unpacked version of
+ ${this.superBlockName}/${this.challengeBlockName}
+ (challenge id ${this.challenge.id}
).
Open the JavaScript console to see test results.
'); + + // text.push(`Edit this HTML file (between <!--s only!)
+ // and run npm repack ???
+ // to incorporate your changes into the challenge database.
'); + if (this.challenge.seed) { + text.push(text, this.challenge.seed.join('\n')); + } + text.push(''); + text.push(''); + + // Q: What is the difference between 'seed' and 'challengeSeed' ? + text.push(''); + text.push('
'); + if (this.challenge.challengeSeed) { + text.push(text, this.challenge.challengeSeed.join('\n')); + } + text.push(''); + text.push(''); + + text.push(''); + text.push('