diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a09c56d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..8c30b9e --- /dev/null +++ b/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/index.css b/index.css new file mode 100644 index 0000000..94a153e --- /dev/null +++ b/index.css @@ -0,0 +1,7 @@ +.codio-assessment-output-diff-del { + background-color: #ffe6e6; +} + +.codio-assessment-output-diff-ins { + background-color: #e9fff2; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..c6f32bd --- /dev/null +++ b/index.html @@ -0,0 +1,34 @@ + + + + + Standard code test assessment + + + + + + + + +
+

+
+
+
+
+
+
+ +
+
+ + diff --git a/index.js b/index.js new file mode 100644 index 0000000..2bb9e83 --- /dev/null +++ b/index.js @@ -0,0 +1,414 @@ +(function (){ + let assessmentOptions = null + let assessment = null + let processing = false + let showAsDiff = false + let currentData = null + let expanded = false + + const LONG_OUTPUT_LENGTH = 20000 + + const isEmptyObject = (obj) => { + for (const prop in obj) { + if (Object.hasOwn(obj, prop)) { + return false; + } + } + + return true; + } + + const updateProcessing = (status) => { + processing = status + refreshResultAndFooter() + } + + const applyStateInitial = (data) => { + const {state, result, ...dataWithoutState} = data + assessment = dataWithoutState.assessment + assessmentOptions = dataWithoutState.options + + render() + } + + const applyState = (data) => { + console.log('assessment iframe applyState', data) + currentData = data + if (!assessment) { + applyStateInitial(data) + return + } + updateCheckButtonText() + refreshResultAndFooter() + renderGuidance() + } + + const onCheck = (event) => { + event.preventDefault() + updateProcessing(true) + + window.codioAssessmentsHelper.send( + window.codioAssessmentsHelper.METHODS.SUBMIT_ANSWER + ) + } + + const onUnblock = (event) => { + event.preventDefault() + codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.UNBLOCK) + } + + const onReset = (event) => { + event.preventDefault() + codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.RESET) + } + + const renderContent = () => { + $('.instructions-text').html(assessment.source.settings.instructions) + } + + const updateVisibility = (el, visible) => { + visible ? el.removeClass('hide') : el.addClass('hide') + } + + const hasDiff = () => { + if (!currentData?.result?.output) { + return false + } + let parsed + try { + parsed = JSON.parse(currentData?.result.output) + if (!parsed.sequence) { + return false + } + } catch { + return false + } + + return parsed.sequence.some((item, pos) => { + if (item && !item.passed) { + const showExpectedAnswer = window.codioAssessmentsHelper.calculateShowExpectedAnswer( + assessmentOptions.eduStartedAssignment, + assessment.source.showExpectedAnswerOption + ) + const showAnswerFromSource = assessmentOptions.showAsTeacher || showExpectedAnswer + const sourceSequence = assessment.source.sequence && assessment.source.sequence[pos] + const sourceExpectedOutput = assessment.source.expectedOutputs && assessment.source.expectedOutputs[pos] + const resultExpectedOutput = !assessment.source.sequence && + currentData?.result?.expectedOutputs && currentData?.result?.expectedOutputs[pos] + const expectedResult = (showAnswerFromSource && (sourceSequence || sourceExpectedOutput)) || + resultExpectedOutput + return !!expectedResult + } + return false + }) + } + + const updateFooterButtons = () => { + const assessmentState = getAssessmentState() + const {teacherInStudentsProject, showModify, isDisabled, canAnswerAgain, answered} = assessmentState + + const checkVisibility = !showModify && assessmentOptions.useSubmitButtons + const checkBtn = $('.check-button') + updateVisibility(checkBtn, checkVisibility) + checkBtn.prop('disabled', isDisabled) + + const unblockVisibility = !teacherInStudentsProject && showModify + updateVisibility($('.unblock-button'), unblockVisibility) + + const state = processing ? window.codioAssessmentsHelper.States.PROGRESS : currentData?.result?.state + const resetVisibility = !showModify && answered && assessmentOptions.owner && + state !== window.codioAssessmentsHelper.States.PROGRESS && !canAnswerAgain + updateVisibility($('.reset-button'), resetVisibility) + + const diffBtn = $('.diff-button') + const diffBtnTitle = showAsDiff ? 'Show output' : 'Show diff' + updateVisibility(diffBtn, hasDiff()) + diffBtn.html(diffBtnTitle) + } + + const updateCheckButtonText = () => { + const footerContainer = $('.codio-assessment-footer') + const {result} = currentData || {} + const caption = window.codioAssessmentsHelper.getButtonCaption( + assessmentOptions, + assessment.source.maxAttemptsCount, + result?.usedAttempts || 0 + ) + footerContainer.find('.check-button').html(caption) + } + + const renderGuidance = () => { + const guidanceBlock = $('.codio-assessment-guidance-block') + guidanceBlock.empty() + const assessmentState = getAssessmentState() + const {result} = currentData || {} + const guidance = window.codioAssessmentsHelper.calculateGuidance( + !assessmentOptions.eduStartedAssignment, + assessmentOptions.showAsTeacher, + assessmentState.answered, + assessment.source, + result ? + { + answerGuidance: result.guidance, + answerPoints: result.points, + attemptsCount: result.usedAttempts, + passed: result.state === window.codioAssessmentsHelper.States.PASS, + isCompletedAndReleased: window.codioAssessmentsHelper.calculateCompletedAndReleased( + assessmentOptions.eduStartedAssignment + ) + } : {} + ) + if (guidance) { + const guidanceContainer = $('
') + const guidanceText = $('
').html(guidance) + guidanceContainer.append(guidanceText) + guidanceBlock.append(guidanceContainer) + } + } + + const onExpandClick = () => { + const {EXPAND, COLLAPSE} = window.codioAssessmentsHelper.METHODS + const action = expanded ? COLLAPSE :EXPAND + expanded = !expanded + window.codioAssessmentsHelper.send(action) + } + + const getValidHtml = (text) => { + return new DOMParser().parseFromString(text, 'text/html').querySelector("body").innerHTML + } + + const diffOutput = (expectedOutput, output) => { + const dmp = new window.DiffMatchPatch() + const pattern_para = /\n/g + const diff = dmp.diff_main(output, expectedOutput) + dmp.diff_cleanupSemantic(diff) + const diffItems = diff.map(item => { + const text = item[1].replace(pattern_para, '¶
') + switch (item[0]) { + case 1: + return `${getValidHtml(text)}` + case -1: + return `${getValidHtml(text)}` + case 0: + default: + return getValidHtml(text) + } + }) + + return `
${diffItems.join('')}
` + } + + const renderOutput = () => { + const {showAsTeacher, eduStartedAssignment} = assessmentOptions + const result = currentData?.result + const currentState = processing ? window.codioAssessmentsHelper.States.PROGRESS : result?.state + const resultClasses = `codio-assessment-result-output codio-assessment-result-output-${currentState}` + let output = null + let currentLimit = LONG_OUTPUT_LENGTH + + function cutOutput(inputText) { + return inputText.length < currentLimit ? inputText : inputText.substring(0, currentLimit) + '\n...' + } + + if (result?.output) { + try { + const parsed = JSON.parse(result.output) + const sequenceSize = parsed?.sequence.length || 0 + if (sequenceSize > 0) { + currentLimit = LONG_OUTPUT_LENGTH / sequenceSize + } + if (parsed.sequence) { + output = parsed.sequence.map((item, pos) => { + let state = `failed` + let reason = '' + let feedback = '' + if (item && item.passed) { + state = `passed` + } else { + let expectedStr = '' + const showExpectedAnswer = window.codioAssessmentsHelper.calculateShowExpectedAnswer( + eduStartedAssignment, + assessment.source.showExpectedAnswerOption + ) + const showAnswerFromSource = showAsTeacher || showExpectedAnswer + const sourceSequence = assessment.source.sequence && assessment.source.sequence[pos] + const sourceExpectedOutput = assessment.source.expectedOutputs && + !isEmptyObject(assessment.source.expectedOutputs[pos]) && + assessment.source.expectedOutputs[pos] + const resultExpectedOutput = !assessment.source.sequence && + result.expectedOutputs && !isEmptyObject(result.expectedOutputs[pos]) && result.expectedOutputs[pos] + const expectedResult = (showAnswerFromSource && (sourceSequence || sourceExpectedOutput)) || + resultExpectedOutput + if (expectedResult) { + if (showAsDiff) { + const truncatedOutput = cutOutput(item.stdout + item.stderr) + reason = diffOutput(expectedResult.output, truncatedOutput) + } else { + const escapedExpectedOutput = window.codioAssessmentsHelper.escapeHTML(expectedResult.output) + expectedStr = `Expected:
${escapedExpectedOutput}
` + + const outputText = cutOutput(window.codioAssessmentsHelper.escapeHTML(item.stdout + item.stderr)) + const outputStr = `Output:
${outputText}
` + + reason = `
${outputStr}${expectedStr}
` + } + } + } + if (!item.passed) { + const sourceSequence = assessment.source.sequence && assessment.source.sequence[pos] + const sourceFeedback = sourceSequence && sourceSequence.showFeedback && sourceSequence.feedback + const resultFeedbackObject = !assessment.source.sequence && assessment.source.feedbacks && + assessment.source.feedbacks[pos] + const feedbackToShow = sourceFeedback || (resultFeedbackObject.show && resultFeedbackObject.feedback) + if (feedbackToShow && !showAsDiff) { + const escapedFeedback = window.codioAssessmentsHelper.escapeHTML(feedbackToShow) + feedback = `
Feedback:
${escapedFeedback}
` + } + } + return `
Check ${pos + 1} ${state}${reason}${feedback}
` + }).join('') + } else if (parsed.error) { + const errorOutput = cutOutput(parsed.error) + output = `
Error: ${errorOutput}
` + } + } catch { + output = cutOutput(result.output) + } + } + const outputEl = $(`
`) + outputEl.attr('tabIndex', 0) + outputEl.attr('aria-label', `Scrollable feedback ${assessment.source.showName ? assessment.source.name : ''}`) + outputEl.html(result?.state !== window.codioAssessmentsHelper.States.RESET ? output : '') + + return outputEl + } + + const renderResult = () => { + const assessmentState = getAssessmentState() + const resultBlock = $('.codio-assessment-results-block') + resultBlock.empty() + if (!assessmentState.answered && !processing) { + return + } + const result = currentData?.result + const state = processing ? window.codioAssessmentsHelper.States.PROGRESS : result?.state + const resultEl = $(`
`) + + const assessmentStatus = window.codioAssessmentsHelper.getAssessmentResultStatus( + assessment.source, result, processing + ) + const iconStr = window.codioAssessmentsHelper.getIconByResultStatus(assessmentStatus) + const iconEl = $(iconStr).addClass(`codio-assessment-result-status-icon ${assessmentStatus}`) + const iconContainer = $('
') + iconContainer.append(iconEl) + resultEl.append(iconContainer) + const resultInfoContainer = $('
') + + if (result?.timestamp) { + const timestamp = new Date(result.timestamp).toLocaleString() + const timestampEl = $(` +
LAST RUN  +on ${timestamp} +
+`) + resultInfoContainer.append(timestampEl) + } + resultInfoContainer.append(renderOutput()) + resultEl.append(resultInfoContainer) + + const resultActionsContainer = $('
') + const expandButton = $(` + +`) + expandButton.attr('aria-label', `Expand output ${assessment.source.showName ? assessment.source.name : ''}`) + expandButton.on('click', onExpandClick) + resultActionsContainer.append(expandButton) + resultEl.append(resultActionsContainer) + resultBlock.append(resultEl) + } + + const getAssessmentState = () => { + const result = currentData ? currentData.result : null + const answered = result?.state && result?.state !== window.codioAssessmentsHelper.States.RESET + const usedAttempts = result?.usedAttempts || 0 + const canAnswerAgain = !assessment.source.maxAttemptsCount || usedAttempts < assessment.source.maxAttemptsCount + const showModify = assessmentOptions.showUnblock && (!answered || canAnswerAgain) + const isDisabled = processing || + assessmentOptions.isDisabled || + result?.state === window.codioAssessmentsHelper.States.PROGRESS || + answered && !canAnswerAgain + const teacherInStudentsProject = assessmentOptions.showAsTeacher && !assessmentOptions.owner + + return { + isDisabled, + answered, + usedAttempts, + canAnswerAgain, + showModify, + teacherInStudentsProject + } + } + + const onToggleDiffView = () => { + showAsDiff = !showAsDiff + renderResult() + updateFooterButtons() + } + + const refreshResultAndFooter = () => { + if (!assessment) { + return + } + renderResult() + updateFooterButtons() + } + + const bindEvents = () => { + $('.check-button').on('click', onCheck) + $('.unblock-button').on('click', onUnblock) + $('.reset-button').on('click', onReset) + $('.diff-button').on('click', onToggleDiffView) + + window.codioAssessmentsHelper.addBodyHeightListener() + } + + const render = () => { + const container = $('.codio-assessment') + const nameEl = container.find('.codio-assessment-name') + assessment.source.showName ? nameEl.text(assessment.source.name) : nameEl.remove() + renderContent() + updateCheckButtonText() + renderGuidance() + refreshResultAndFooter() + bindEvents() + container.removeClass('hide') + } + + const processMessage = (jsonData) => { + try { + const {method, data} = JSON.parse(jsonData) + console.log('assessment iframe processMessage', jsonData, method, data) + switch (method) { + case window.codioAssessmentsHelper.METHODS.GET_STYLES_RESPONSE: + window.codioAssessmentsHelper.addStyle(data.css) + break + case window.codioAssessmentsHelper.METHODS.GET_STATE_RESPONSE: + updateProcessing(false) + applyState(data) + break + case window.codioAssessmentsHelper.METHODS.CALLBACK: { + window.codioAssessmentsHelper.processCallback(data) + break + } + } + } catch {} + } + + window.addEventListener('load', () => { + window.codioAssessmentsHelper.registerMessageListener(processMessage) + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.GET_STATE) + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.GET_STYLES) + }) +})() diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..e9b1d2c --- /dev/null +++ b/metadata.json @@ -0,0 +1,19 @@ +{ + "name": "Standard Code Test", + "type": "assessment", + "properties": { + "type": "code-output-compare", + "icon": "./icon.svg", + "defaultHeight": 500, + "gradingControls": { + "points": true, + "allowPartialPoints": true, + "useMaximumScore": true, + "commandOptions": true, + "testSequence": true, + "showExpectedAnswer": true, + "definedNumberOfAttempts": true, + "rationale": true + } + } +} diff --git a/settings.css b/settings.css new file mode 100644 index 0000000..3d6e420 --- /dev/null +++ b/settings.css @@ -0,0 +1,5 @@ +.settings-commands { + display: flex; + flex-direction: row; + gap: 10px; +} diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..f17c4b0 --- /dev/null +++ b/settings.html @@ -0,0 +1,49 @@ + + + + + Settings + + + + + + + + + +
+
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..1ccd9b8 --- /dev/null +++ b/settings.js @@ -0,0 +1,55 @@ +(function () { + const DEFAULT_TIMEOUT = 30 + let instructionsEditor = null + + const collectSettings = () => { + const errors = [] + const instructions = instructionsEditor.getContent() + const command = $('#command').val(); + const preExecuteCommand = $('#preExecCommand').val(); + const timeout = parseInt($('#timeout').val(), 10); + + !instructions && errors.push('Instructions field must be completed'); + !command && errors.push('Command field must be completed'); + + return { + data: {instructions, command, preExecuteCommand, timeout: timeout || DEFAULT_TIMEOUT}, + errors + }; + } + + const exportSettings = () => { + const data = collectSettings(); + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.EXPORT_SETTINGS_RESPONSE, data); + } + + const applySettings = (settings = {}) => { + instructionsEditor.setContent(settings.instructions || '') + $('#command').val(settings.command || ''); + $('#preExecCommand').val(settings.preExecuteCommand || ''); + $('#timeout').val(settings.timeout || DEFAULT_TIMEOUT); + } + + const processMessage = (jsonData) => { + console.log('settings iframe processMessage', jsonData) + try { + const {method, data} = JSON.parse(jsonData); + switch (method) { + case window.codioAssessmentsHelper.METHODS.EXPORT_SETTINGS: + exportSettings(); + break; + case window.codioAssessmentsHelper.METHODS.GET_SETTINGS_RESPONSE: + applySettings(data.settings); + break; + } + } catch {} + } + + const onLoad = async () => { + window.codioAssessmentsHelper.registerMessageListener(processMessage) + window.codioAssessmentsHelper.send(window.codioAssessmentsHelper.METHODS.GET_SETTINGS) + instructionsEditor = window.codioAssessmentsHelper.initializeMarkdownEditor('instructions', 'instructions-command-bar') + } + + window.addEventListener('load', onLoad); +})()