UNPKG

@sprucelabs/spruce-cli

Version:

Command line interface for building Spruce skills.

321 lines (262 loc) • 10.2 kB
import { Schema, SchemaFieldsByName, SchemaFieldNames, SchemaError, } from '@sprucelabs/schema' import chalk from 'chalk' import { shuffle } from 'lodash' import { GraphicsInterface } from '../types/cli.types' import FormComponent, { FormOptions, FormPresentationOptions, } from './FormComponent' interface QuizMultipleChoiceQuestion { type: 'select' question: string answers: string[] } interface QuizTextQuestion { type: 'text' question: string answer: string } type QuizQuestions = Record< string, QuizMultipleChoiceQuestion | QuizTextQuestion > enum AnswerValidity { Correct = 'correct', Incorrect = 'incorrect', } interface QuizPresentationOptions<T extends Schema, Q extends QuizQuestions> extends Omit<FormPresentationOptions<T>, 'fields'> { questions?: QuizAnswerFieldNames<Q>[] randomizeQuestions?: boolean } type QuizAnswerFieldNames<Q extends QuizQuestions> = Extract<keyof Q, string> type QuizAnswers<Q extends QuizQuestions> = { [K in QuizAnswerFieldNames<Q>]: string } type QuizAnswerValidities<Q extends QuizQuestions> = { [K in QuizAnswerFieldNames<Q>]: AnswerValidity } interface QuizPresentationResults<Q extends QuizQuestions> { /** The answers that were given */ answers: QuizAnswers<Q> /** The answers right or wrong */ answerValidities: QuizAnswerValidities<Q> /** The percent of correct answers given */ percentCorrect: number /** How long it took them to take the quiz in ms */ time: { startTimeMs: number endTimeMs: number totalTimeSec: number } /** How many were correct */ totalCorrect: number /** How many were wrong */ totalWrong: number /** How many questions we ended up taking */ totalQuestions: number } interface QuizOptions<T extends Schema, Q extends QuizQuestions> extends Omit<FormOptions<T>, 'schema'> { /** Should we randomize the questions */ randomizeQuestions?: boolean /** The questions we are asking */ questions: Q } export default class QuizComponent<T extends Schema, Q extends QuizQuestions> { public formBuilder: FormComponent<T> public term: GraphicsInterface public randomizeQuestions = true public originalQuestions: QuizQuestions public lastResults?: QuizPresentationResults<Q> public constructor(options: QuizOptions<T, Q>) { // We're going to build a schema from the questions and pass that to the form builder const definition = this.buildSchemaFromQuestions(options.questions) // Track questions for later reference this.originalQuestions = options.questions // Construct new form builder this.formBuilder = new FormComponent<T>({ ...options, schema: definition, }) // Set state locally this.term = options.ui this.randomizeQuestions = options.randomizeQuestions ?? true } /** Present the quiz */ public async present( options: QuizPresentationOptions<T, Q> = {} ): Promise<QuizPresentationResults<Q>> { const { questions = this.formBuilder .getNamedFields() .map((nf) => nf.name) as QuizAnswerFieldNames<Q>[], randomizeQuestions = this.randomizeQuestions, } = options const startTime = new Date().getTime() // Pull out answers const fields = randomizeQuestions ? shuffle(questions) : questions // Ask for the answers const results = await this.formBuilder.present({ ...options, fields: fields as SchemaFieldNames<T>[], }) // Generate stats const answers: Partial<QuizAnswers<Q>> = {} const answerValidities: Partial<QuizAnswerValidities<Q>> = {} const totalQuestions = questions.length let totalCorrect = 0 const questionNames = Object.keys(results) as QuizAnswerFieldNames<Q>[] questionNames.forEach((questionName) => { const fieldName = questionName as SchemaFieldNames<T> const answer = (results[fieldName] as string) || '' const [validity, idx] = answer.split('-') // Get the field to tell type const { field } = this.formBuilder .getNamedFields() .find((namedField) => namedField.name === fieldName) || {} if (!field) { throw new Error('Field issue in QuizComponent') } const fieldDefinition = field.definition switch (fieldDefinition.type) { case 'select': // Pull the original multiple choice, we can cast it as multiple choice // question with confidence answers[questionName] = ( this.originalQuestions[ questionName ] as QuizMultipleChoiceQuestion ).answers[parseInt(idx)] break default: // @ts-ignore TODO proper questions to schema should fix this because we only support a few fields answers[questionName] = results[fieldName] } // Track validity if (validity === AnswerValidity.Correct) { totalCorrect = totalCorrect + 1 answerValidities[questionName] = AnswerValidity.Correct } else { answerValidities[questionName] = AnswerValidity.Incorrect } }, 0) const totalWrong = totalQuestions - totalCorrect // Track time const endTime = new Date().getTime() this.lastResults = { percentCorrect: totalCorrect / totalQuestions, totalCorrect, totalWrong, answerValidities: answerValidities as QuizAnswerValidities<Q>, answers: answers as QuizAnswers<Q>, totalQuestions, time: { startTimeMs: startTime, endTimeMs: endTime, totalTimeSec: +((endTime - startTime) / 1000).toFixed(1), }, } return this.lastResults } public async scorecard( options: { results?: QuizPresentationResults<Q> headline?: string } = {} ) { const { headline, results = this.lastResults } = options const { term } = this if (!results) { throw new SchemaError({ code: 'INVALID_PARAMETERS', parameters: [], }) } term.clear() term.renderHero(headline ?? 'Quiz results!') const testResults: Record<string, string> = {} this.formBuilder.getNamedFields().forEach((namedField) => { const { name, field } = namedField const questionFieldName = name as QuizAnswerFieldNames<Q> // Get results const isCorrect = results.answerValidities[questionFieldName] === AnswerValidity.Correct const guessedAnswer = `${results.answers[questionFieldName]}` // Build the real answer let correctAnswer = '' const originalQuestion = this.originalQuestions[questionFieldName] switch (originalQuestion.type) { case 'select': correctAnswer = originalQuestion.answers[0] break default: // All options just pass through the answer tied to the question during instantiation correctAnswer = originalQuestion.answer } const objectKey = field.label || '**missing' if (isCorrect) { testResults[objectKey] = `${chalk.bgGreenBright.black( 'Correct!' )} ${guessedAnswer} ` } else { testResults[objectKey] = `${chalk.bgRedBright.black( 'Wrong!' )} ${chalk.strikethrough(guessedAnswer)} -> ${correctAnswer}` } }) term.renderObject(testResults) term.renderLine(`# questions: ${results.totalQuestions}`) term.renderLine(`# correct: ${results.totalCorrect}`) term.renderHeadline( `Your score: ${(results.percentCorrect * 100).toFixed(1)}%` ) await term.waitForEnter() } /** Takes questions and builds a schema */ private buildSchemaFromQuestions(questions: QuizQuestions): T { // TODO change SchemaFields to something based on schema generated from questions const fields: SchemaFieldsByName = {} Object.keys(questions).forEach((fieldName) => { const question = questions[fieldName] switch (question.type) { case 'select': fields[fieldName] = { type: question.type, label: question.question, options: { choices: shuffle( question.answers.map((question, idx) => ({ value: idx === 0 ? `${AnswerValidity.Correct}-${idx}` : `${AnswerValidity.Incorrect}-${idx}`, label: question, })) ), }, } break default: fields[fieldName] = { type: question.type, label: question.question, } } }) //@ts-ignore TODO better mapping of questions to schema definition const definition: T = { id: 'quizGenerated', name: 'Generated quiz', fields, } return definition } }