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(` + + +
+ + + `); + const { window } = jsdom; + + // Mock DOM for ReactDOM tests + document = window.document; + global.window = window; + global.document = window.document; -function validateObjectId(id, title) { - if (notMongoId(id)) { - throw new Error(`Expected a vaild ObjectId for ${title}, got ${id}`); } - const idIndex = _.findIndex(existingIds, existing => id === existing); - if (idIndex !== -1) { - throw new Error(` - All challenges must have a unique id. - The id for ${title} is already assigned - `); + /* eslint-enable no-unused-vars */ + try { + (() => { + return eval( + head + '\n;;' + + solution + '\n;;' + + tail + '\n;;' + + test + ); + })(); + } catch (e) { + tapTest.fail(e); } - existingIds = [ ...existingIds, id ]; - return; -} - -function createIsAssert(t, isThing) { - const { assert } = t; - return function() { - const args = [...arguments]; - args[0] = isThing(args[0]); - assert.apply(t, args); - }; -} - -function fillAssert(t) { - const assert = t.assert; - - assert.isArray = createIsAssert(t, _.isArray); - assert.isBoolean = createIsAssert(t, _.isBoolean); - assert.isString = createIsAssert(t, _.isString); - assert.isNumber = createIsAssert(t, _.isNumber); - assert.isUndefined = createIsAssert(t, _.isUndefined); - - assert.deepEqual = t.deepEqual; - assert.equal = t.equal; - assert.strictEqual = t.equal; - - assert.sameMembers = function sameMembers() { - const [ first, second, ...args] = arguments; - assert.apply( - t, - [ - _.difference(first, second).length === 0 && - _.difference(second, first).length === 0 - ].concat(args) - ); - }; - - assert.includeMembers = function includeMembers() { - const [ first, second, ...args] = arguments; - assert.apply(t, [_.difference(second, first).length === 0].concat(args)); - }; - - assert.match = function match() { - const [value, regex, ...args] = arguments; - assert.apply(t, [regex.test(value)].concat(args)); - }; - - return assert; } function createTest({ @@ -84,7 +114,8 @@ function createTest({ redux = false, reactRedux = false }) { - validateObjectId(id, title); + mongoIds.check(id, title); + solutions = solutions.filter(solution => !!solution); tests = tests.filter(test => !!test); @@ -92,7 +123,10 @@ function createTest({ const isAsync = s => s.includes('(async () => '); if (isAsync(tests.join(''))) { console.log(`Replacing Async Tests for Challenge ${title}`); - tests = tests.map(t => isAsync(t) ? "assert(true, 'message: great');" : t); + tests = tests.map(challengeTestSource => + isAsync(challengeTestSource) ? + "assert(true, 'message: great');" : + challengeTestSource); } head = head.join('\n'); @@ -106,116 +140,26 @@ function createTest({ } return Observable.fromCallback(tape)(title) - .doOnNext(t => solutions.length ? t.plan(plan) : t.end()) - .flatMap(t => { + .doOnNext(tapTest => + solutions.length ? tapTest.plan(plan) : tapTest.end()) + .flatMap(tapTest => { if (solutions.length <= 0) { - t.comment('No solutions for ' + title); + tapTest.comment('No solutions for ' + title); return Observable.just({ title, type: 'missing' }); } - return Observable.just(t) - .map(fillAssert) + return Observable.just(tapTest) + .map(addAssertsToTapTest) /* eslint-disable no-unused-vars */ // assert and code used within the eval .doOnNext(assert => { solutions.forEach(solution => { - // Original code string - const originalCode = solution; tests.forEach(test => { - 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(` - - -
- - - `); - const { window } = jsdom; - - // Mock DOM for ReactDOM tests - document = window.document; - global.window = window; - global.document = window.document; - - } - - const editor = { - getValue() { return code; }, - getOriginalCode() { return originalCode; } - }; - /* eslint-enable no-unused-vars */ - try { - (() => { - return eval( - head + '\n;;' + - solution + '\n;;' + - tail + '\n;;' + - test - ); - })(); - } catch (e) { - t.fail(e); - } + evaluateTest(solution, assert, react, redux, reactRedux, + head, tail, test, tapTest); }); }); }) @@ -244,10 +188,12 @@ Observable.from(getChallenges()) if (noSolutions) { console.log( '# These challenges have no solutions\n- [ ] ' + - noSolutions.join('\n- [ ] ') + noSolutions.join('\n- [ ] ') ); } }, - err => { throw err; }, + err => { + throw err; + }, () => process.exit(0) ); diff --git a/unpack.js b/unpack.js new file mode 100644 index 0000000000..36410e4653 --- /dev/null +++ b/unpack.js @@ -0,0 +1,87 @@ +/* eslint-disable no-eval, no-process-exit */ +import fs from 'fs-extra'; +import path from 'path'; +import browserify from 'browserify'; +import getChallenges from './getChallenges'; +import {UnpackedChallenge, ChallengeFile} from './unpackedChallenge'; + +// Unpack all challenges +// from all seed/challenges/00-foo/bar.json files +// into seed/unpacked/00-foo/bar/000-id.html files +// +// todo: unpack translations too +// todo: use common/app/routes/Challenges/utils/index.js:15 maps +// to determine format/style for non-JS tests +// todo: figure out embedded images etc. served from elsewhere in the project +// todo: prettier/clearer CSS + +// bundle up the test-running JS +function createUnpackedBundle() { + let unpackedFile = path.join(__dirname, 'unpacked.js'); + let b = browserify(unpackedFile).bundle(); + b.on('error', console.error); + let unpackedBundleFile = + path.join(__dirname, 'unpacked', 'unpacked-bundle.js'); + const bundleFileStream = fs.createWriteStream(unpackedBundleFile); + bundleFileStream.on('finish', () => { + console.log('Wrote bundled JS into ' + unpackedBundleFile); + }); + bundleFileStream.on('pipe', () => { + console.log('Writing bundled JS...'); + }); + bundleFileStream.on('error', console.error); + b.pipe(bundleFileStream); + // bundleFileStream.end(); // do not do this prematurely! +} + +let currentlyUnpackingDir = null; + +function unpackChallengeBlock(challengeBlock) { + let challengeBlockPath = path.parse(challengeBlock.fileName); + let unpackedChallengeBlockDir = path.join( + __dirname, + 'unpacked', + challengeBlockPath.dir, + challengeBlockPath.name + ); + fs.mkdirp(unpackedChallengeBlockDir, (err) => { + if (err && err.code !== 'EEXIST') { + console.log(err); + throw err; + } + + if (currentlyUnpackingDir !== challengeBlockPath.dir) { + currentlyUnpackingDir = challengeBlockPath.dir; + console.log(`Unpacking into ${currentlyUnpackingDir}:`); + } + console.log(` ${challengeBlock.name}`); + + // write a copy of the challenge block into unpacked dir + delete challengeBlock.fileName; + delete challengeBlock.superBlock; + delete challengeBlock.superOrder; + let challengeBlockCopy = + new ChallengeFile( + unpackedChallengeBlockDir, + challengeBlockPath.name, + '.json'); + challengeBlockCopy.write(JSON.stringify(challengeBlock, null, 2)); + + // unpack each challenge into an HTML file + let index = 0; + challengeBlock.challenges.forEach(challenge => { + new UnpackedChallenge( + unpackedChallengeBlockDir, + challenge, + index + ).unpack(); + index += 1; + }); + }); +} + +createUnpackedBundle(); +let challenges = getChallenges(); +challenges.forEach(challengeBlock => { + unpackChallengeBlock(challengeBlock); +}); diff --git a/unpacked.css b/unpacked.css new file mode 100644 index 0000000000..807168aac7 --- /dev/null +++ b/unpacked.css @@ -0,0 +1,22 @@ +body { + font-family: sans-serif; +} + +script.unpacked, pre.unpacked { + display: block; + font-family: monospace; + font-size: 14px; + white-space: pre; + border: 1px solid blue; + background: #EFEFEF; + padding: .5em 1em; + margin: 1em; + overflow: auto; +} + +div.unpacked { + border: 1px solid black; + padding: .5em 1em; + margin: 1em; + overflow: auto; +} diff --git a/unpacked.js b/unpacked.js new file mode 100644 index 0000000000..4c9d2ad829 --- /dev/null +++ b/unpacked.js @@ -0,0 +1,18 @@ +/* eslint-disable no-unused-vars,max-len */ +window._ = require('lodash'); +window.test = require('tape').test; + +// check for Browser TAP chrome extension, available here: +// https://chrome.google.com/webstore/detail/browser-tap/ncfblaiipckncgeipgmpdioedcdmofei?hl=en +if (window.tapExtension) { + window.test = window.tapExtension(window.test); +} + +window.addAssertsToTapTest = require('./addAssertsToTapTest'); +window.$ = require('jquery'); + +test('framework', function(t) { + t.plan(1); + t.equal(1, 1, 'one equals one'); +}); + diff --git a/unpackedChallenge.js b/unpackedChallenge.js new file mode 100644 index 0000000000..f419c2eb26 --- /dev/null +++ b/unpackedChallenge.js @@ -0,0 +1,258 @@ +/* eslint-disable no-inline-comments */ +import fs from 'fs-extra'; +import path from 'path'; +import _ from 'lodash'; + +const jsonLinePrefix = '//--JSON:'; + +class ChallengeFile { + constructor(dir, name, suffix) { + this.dir = dir; + this.name = name; + this.suffix = suffix; + } + + filePath() { + return path.join(this.dir, this.name + this.suffix); + } + + write(contents) { + if (_.isArray(contents)) { + contents = contents.join('\n'); + } + fs.writeFile(this.filePath(), contents, err => { + if (err) { + throw err; + } + }); + } + + readChunks() { + // todo: make this work async + // todo: make sure it works with encodings + let data = fs.readFileSync(this.filePath()); + let lines = data.toString().split(/(?:\r\n|\r|\n)/g); + let chunks = {}; + let readingChunk = null; + lines.forEach(line => { + let chunkEnd = /( { + if (_.isString(part)) { + out.push(part.toString()); + } else { + // Descriptions are weird since sometimes they're text and sometimes + // they're "steps" which appear one at a time with optional pix and + // captions and links, or "questions" with choices and expanations... + // For now we preserve non-string descriptions via JSON but this is + // not a great solution. + // It would be better if "steps" and "description" were separate fields. + // For the record, the (unnamed) fields in step are: + // 0: image URL + // 1: caption + // 2: text + // 3: link URL + out.push(jsonLinePrefix + JSON.stringify(part)); + } + }); + // indent by 2 + return out; + } + + expandedTests(tests) { + if (!tests) { + return []; + } + let out = []; + tests.forEach(test => { + if (_.isString(test)) { + out.push(test); + } else { + // todo: figure out what to do about these id-title challenge links + out.push(jsonLinePrefix + JSON.stringify(test)); + } + }); + return out; + } + + unpackedHTML() { + let text = []; + text.push(''); + text.push(''); + text.push(''); + text.push(''); + text.push(''); + text.push(''); + text.push(''); + text.push(`

${this.challenge.title}

`); + text.push(`

This is the unpacked version of + ${this.superBlockName}/${this.challengeBlockName} + (challenge id ${this.challenge.id}).

`); + text.push('

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.

`); + + text.push(''); + text.push('

Description

'); + text.push('
'); + text.push(''); + text.push(this.expandedDescription(this.challenge.description).join('\n')); + text.push(''); + text.push('
'); + + text.push(''); + text.push('

Seed

'); + text.push('
');
+    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('

Challenge Seed

'); + text.push('
');
+    if (this.challenge.challengeSeed) {
+      text.push(text, this.challenge.challengeSeed.join('\n'));
+    }
+    text.push('');
+    text.push('
'); + + text.push(''); + text.push('

Head

'); + text.push(''); + + text.push(''); + text.push('

Solution

'); + text.push( + ''); + + text.push(''); + text.push('

Tail

'); + text.push(''); + + text.push(''); + text.push('

Tests

'); + text.push(''); + + text.push(''); + text.push(''); + text.push(''); + return text; + } +} + +export {UnpackedChallenge}; +