Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for local mappings and conditional questions #2

Merged
merged 5 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ ERROR:=$(error This version of make does not support required 'grouped-target' (
endif

.DELETE_ON_ERROR:

.PRECIOUS: last-test.txt last-lint.txt

.PHONY: all build clean-test lint lint-fix qa test

default: build
Expand Down
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
"main": "dist/question-and-answer.js",
"module": "dist/question-and-answer.mjs",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "make build",
"test": "make test",
"preversion": "make qa",
"lint": "make lint",
"lint:fix": "make lint-fix",
"qa": "make qa"
},
"keywords": [],
"author": "Zane Rockenbaugh <zane@liquid-labs.com>",
Expand All @@ -20,6 +25,7 @@
},
"devDependencies": {
"@liquid-labs/catalyst-scripts": "^1.0.0-alpha.58",
"http-errors": "^2.0.0",
"mock-stdin": "^1.0.0"
}
}
2 changes: 1 addition & 1 deletion src/lib/index.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './questioner'
export * from './questioner'
42 changes: 23 additions & 19 deletions src/lib/questioner.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as readline from 'node:readline'

import createError from 'http-errors'

import { Evaluator } from '@liquid-labs/condition-eval'

const Questioner = class {
Expand All @@ -8,36 +10,40 @@ const Questioner = class {

async #doQuestions({ input = process.stdin, output = process.stdout }) {
for (const q of this.#interogationBundle.questions) {
const evaluator = new Evaluator({ parameters: this.#values })
const evaluator = new Evaluator({ parameters : this.#values })

if (q.condition === undefined || evaluator.evalTruth(q.condition)) {
// to avoid the 'MaxListenersExceededWarning', we have to create the rl inside the loop because everytime we do
// our loop it ends up adding listeners for whatever reason.
const rl = readline.createInterface({ input, output, terminal: false })
const rl = readline.createInterface({ input, output, terminal : false })

try {
rl.setPrompt("\n" + q.prompt + ' ') // add newline for legibility
rl.setPrompt('\n' + q.prompt + ' ') // add newline for legibility
rl.prompt()

const it = rl[Symbol.asyncIterator]()
const answer = await it.next() // TODO: check that 'answer' is in the right form

if (q.paramType === undefined || ( /bool(ean)?/i ).test(q.paramType)) {
const value = (/^\s*(?:y(?:es)?|t(?:rue)?)\s*$/i).test(answer.value) ? true : false
if (q.paramType === undefined || (/bool(ean)?/i).test(q.paramType)) {
const value = !!(/^\s*(?:y(?:es)?|t(?:rue)?)\s*$/i).test(answer.value)
this.#values[q.parameter] = value
}
else if (( /string|numeric|float|int(eger)?/i ).test(q.paramType)) {
if (( /numeric/i ).test(q.paramType) && ! isNaN(answer.value)) {
throw new Error(`Parameter '${q.parameter}' must be a numeric type.`)
else if ((/string|numeric|float|int(eger)?/i).test(q.paramType)) {
if ((/int(?:eger)?/i).test(q.paramType)) {
if (isNaN(answer.value)) {
throw createError.BadRequest(`Parameter '${q.parameter}' must be a numeric type.`)
}
this.#values[q.parameter] = parseInt(answer.value)
}
else if (( /float/i ).test(q.paramType) && isNaN(answer.value) && ( /-?\\d+\\.\\d+/ ).test(answer.value)) {
throw new Error(`Parameter '${q.parameter}' must be a (basic) floating point number.`)
else if ((/float|numeric/i).test(q.paramType)) {
if (isNaN(answer.value) && (/-?\\d+\\.\\d+/).test(answer.value)) {
throw new Error(`Parameter '${q.parameter}' must be a (basic) floating point number.`)
}
this.#values[q.parameter] = parseFloat(answer.value)
}
else if (( /int(eger)?/i ).test(q.paramType) && isNaN(answer.value) && ( /-?\\d+/ ).test(answer.value)) {
throw new Error(`Parameter '${q.parameter}' must be an integer.`)
else { // treat as a string
this.#values[q.parameter] = answer.value
}

this.#values[q.parameter] = answer.value
}
else {
throw new Error(`Unknown parameter type '${q.paramType}' in 'questions' section.`)
Expand All @@ -50,8 +56,6 @@ const Questioner = class {
finally { rl.close() }
}
}

return
}

get interogationBundle() { return this.#interogationBundle } // TODO: clone
Expand All @@ -60,14 +64,14 @@ const Questioner = class {

processMappings(mappings) {
mappings.forEach((mapping) => {
const evaluator = new Evaluator({ parameters: this.#values })
const evaluator = new Evaluator({ parameters : this.#values })

if (mapping.condition === undefined || evaluator.evalTruth(mapping.condition)) {
mapping.maps.forEach((map) => {
if (map.source !== undefined) {
this.#values[map.target] = this.#values[map.source]
}
else if (map.value !== undefined) {
else if (map.value !== undefined) {
this.#values[map.target] = map.value
}
else {
Expand All @@ -93,4 +97,4 @@ const Questioner = class {
get values() { return this.#values } // TODO: clone
}

export { Questioner }
export { Questioner }
7 changes: 7 additions & 0 deletions src/lib/test/conditional-question.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Questioner } from '../questioner'
import { conditionalQuestionIB } from './test-data'

const questioner = new Questioner()
questioner.interogationBundle = conditionalQuestionIB

questioner.question()
88 changes: 62 additions & 26 deletions src/lib/test/questioner.test.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,14 @@
/* global describe expect test */
import * as fsPath from 'node:path'
import { spawn } from 'node:child_process'

import { stdin } from 'mock-stdin'

import { simpleIB, simpleMapIB, simpleLocalMapIB, DO_YOU_LIKE_MILK, IS_THE_COMPANY_THE_CLIENT, IS_THIS_THE_END } from './test-data'
import { Questioner } from '../questioner'

const input = stdin()

const simplePrompt = "Is the Company the client? (y=client/n=contractor)"
const simpleIB = {
questions: [
{ prompt: simplePrompt, parameter: "IS_CLIENT" }
]
}

const simpleMapIB = structuredClone(simpleIB)
simpleMapIB.mappings = [
{
"condition": "IS_CLIENT",
"maps": [
{ "target": "ORG_COMMON_NAME", "value": "us" },
]
},
{
"condition": "!IS_CLIENT",
"maps": [
{ "target": "ORG_COMMON_NAME", "value": "them" },
]
}
]

describe('Questioner', () => {
test('can process a simple boolean question', (done) => {
const questioner = new Questioner()
Expand All @@ -37,19 +18,74 @@ describe('Questioner', () => {
expect(questioner.values.IS_CLIENT).toBe(true)
done()
})

input.send('yes\n')
})

test.each([ ['yes', 'us'], ['no', 'them'] ])('Global map %s -> %s', (answer, mapping, done) => {
test.each([['yes', 'us'], ['no', 'them']])('Global map %s -> %s', (answer, mapping, done) => {
const questioner = new Questioner()
questioner.interogationBundle = simpleMapIB

questioner.question({ input }).then(() => {
expect(questioner.values.ORG_COMMON_NAME).toBe(mapping)
done()
})
input.send(answer + '\n')
})

test.each([['yes', 'us'], ['no', 'them']])('Local map %s -> %s', (answer, mapping, done) => {
const questioner = new Questioner()
questioner.interogationBundle = simpleLocalMapIB

questioner.question({ input }).then(() => {
expect(questioner.values.ORG_COMMON_NAME).toBe(mapping)
done()
})
input.send(answer + '\n')
})
})

test.each([['yes', DO_YOU_LIKE_MILK], ['no', IS_THIS_THE_END]]) // eslint-disable-line func-call-spacing
('Conditional question %s -> %s', (answer, followup, done) => { // eslint-disable-line no-unexpected-multiline
const testScriptPath = fsPath.join(__dirname, 'conditional-question.js')

// You cannot (as of Node 19.3.0) listen for events on your own stdout, so we have to create a child process.
const child = spawn('node', [testScriptPath, answer])

child.stdout.resume()
child.stdout.once('data', (output) => {
expect(output.toString().trim()).toBe(IS_THE_COMPANY_THE_CLIENT)

child.stdout.once('data', (output) => {
expect(output.toString().trim()).toBe(followup)
child.stdin.write('yes\n')
if (answer === 'yes') {
child.stdin.write('yes\n')
}

child.kill('SIGINT')
done()
})
})

child.stdin.write(answer + '\n')
})

test.each([
['true', 'boolean', true],
['true', 'bool', true],
['true', 'string', 'true'],
['5', 'integer', 5],
['5.5', 'float', 5.5],
['6.6', 'numeric', 6.6]
])("Value '%s' type '%s' -> %p", (value, type, expected, done) => {
const questioner = new Questioner()
const ib = structuredClone(simpleIB)
ib.questions[0].paramType = type
questioner.interogationBundle = ib

questioner.question({ input }).then(() => {
expect(questioner.values.IS_CLIENT).toBe(expected)
done()
})
input.send(value + '\n')
})
})
43 changes: 43 additions & 0 deletions src/lib/test/test-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const IS_THE_COMPANY_THE_CLIENT = 'Is the Company the client? (y=client/n=contractor)'
const simpleIB = {
questions : [
{ prompt : IS_THE_COMPANY_THE_CLIENT, parameter : 'IS_CLIENT' }
]
}

const commonMapping = [
{
condition : 'IS_CLIENT',
maps : [
{ target : 'ORG_COMMON_NAME', value : 'us' }
]
},
{
condition : '!IS_CLIENT',
maps : [
{ target : 'ORG_COMMON_NAME', value : 'them' }
]
}
]

const simpleMapIB = structuredClone(simpleIB)
simpleMapIB.mappings = structuredClone(commonMapping)

const simpleLocalMapIB = structuredClone(simpleIB)
simpleLocalMapIB.questions[0].mappings = structuredClone(commonMapping)

const DO_YOU_LIKE_MILK = 'Do you like milk?'
const IS_THIS_THE_END = 'Is this the end?'
const conditionalQuestionIB = structuredClone(simpleIB)
conditionalQuestionIB.questions.push({ condition : 'IS_CLIENT', prompt : DO_YOU_LIKE_MILK, parameter : 'LIKES_MILK' })
conditionalQuestionIB.questions.push({ prompt : IS_THIS_THE_END, parameter : 'IS_END' })

export {
IS_THE_COMPANY_THE_CLIENT,
DO_YOU_LIKE_MILK,
IS_THIS_THE_END,
simpleIB,
simpleMapIB,
simpleLocalMapIB,
conditionalQuestionIB
}