feat: add exam environment (#50356)

* feat: add exam environment

* fix: cypress

* fix: lint-ts

* fix: lint-js

* fix: codefactor

* fix: don't translate comments from new JS spreadsheet

* fix: more tests

* fix: challenge schema

* fix: external curriculum test?

* fix: add more comments
This commit is contained in:
Tom
2023-05-18 13:33:43 +02:00
committed by GitHub
parent df12c72f46
commit 4dfca3c560
38 changed files with 1223 additions and 39 deletions
+38
View File
@@ -63,6 +63,13 @@ export default async function bootChallenge(app, done) {
backendChallengeCompleted
);
api.post(
'/exam-challenge-completed',
send200toNonUser,
isValidChallengeCompletion,
examChallengeCompleted
);
api.post(
'/save-challenge',
send200toNonUser,
@@ -423,6 +430,37 @@ async function backendChallengeCompleted(req, res, next) {
});
}
async function examChallengeCompleted(req, res, next) {
// TODO: verify shape of exam results
const { user, body = {} } = req;
const completedChallenge = pick(body, ['id', 'examResults']);
completedChallenge.completedDate = Date.now();
try {
await user.getCompletedChallenges$().toPromise();
} catch (e) {
return next(e);
}
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
completedChallenge.id,
completedChallenge
);
user.updateAttributes(updateData, err => {
if (err) {
return next(err);
}
return res.json({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate
});
});
}
async function saveChallenge(req, res, next) {
const user = req.user;
const { savedChallenges = [] } = user;
+10
View File
@@ -1000,6 +1000,16 @@
}
}
},
"example-certification": {
"title": "Example Certification",
"intro": ["placeholder"],
"blocks": {
"example-certification-exam": {
"title": "Example Certification Exam",
"intro": ["placeholder"]
}
}
},
"misc-text": {
"certification": "{{cert}} Certification",
"browse-other": "Browse our other free certifications\n(we recommend doing these in order)",
@@ -72,9 +72,14 @@
"go-to-settings": "Go to settings to claim your certification",
"click-start-course": "Start the course",
"click-start-project": "Start the project",
"click-start-exam": "Start the exam",
"change-language": "Change Language",
"resume-project": "Resume project",
"start-project": "Start project"
"start-project": "Start project",
"previous-question": "Previous question",
"next-question": "Next question",
"finish-exam": "Finish the exam",
"submit-exam-results": "Submit my results"
},
"landing": {
"big-heading-1": "Learn to code — for free.",
+2 -1
View File
@@ -33,7 +33,8 @@ const iconMap = {
[SuperBlocks.CodingInterviewPrep]: Algorithm,
[SuperBlocks.TheOdinProject]: VikingHelmet,
[SuperBlocks.ProjectEuler]: Graduation,
[SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra
[SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra,
[SuperBlocks.ExampleCertification]: ResponsiveDesign
};
const generateIconComponent = (
+7 -1
View File
@@ -22,6 +22,7 @@ import {
} from '../../redux/actions';
import {
isSignedInSelector,
examInProgressSelector,
userSelector,
isOnlineSelector,
isServerOnlineSelector,
@@ -53,6 +54,7 @@ import { Themes } from '../settings/theme';
const mapStateToProps = createSelector(
isSignedInSelector,
examInProgressSelector,
flashMessageSelector,
isOnlineSelector,
isServerOnlineSelector,
@@ -61,6 +63,7 @@ const mapStateToProps = createSelector(
userSelector,
(
isSignedIn,
examInProgress: boolean,
flashMessage,
isOnline: boolean,
isServerOnline: boolean,
@@ -69,6 +72,7 @@ const mapStateToProps = createSelector(
user: User
) => ({
isSignedIn,
examInProgress,
flashMessage,
hasMessage: !!flashMessage.message,
isOnline,
@@ -102,6 +106,7 @@ interface DefaultLayoutProps extends StateProps, DispatchProps {
showFooter?: boolean;
isChallenge?: boolean;
block?: string;
examInProgress: boolean;
showCodeAlly: boolean;
superBlock?: string;
}
@@ -116,6 +121,7 @@ const getSystemTheme = () =>
function DefaultLayout({
children,
hasMessage,
examInProgress,
fetchState,
flashMessage,
isOnline,
@@ -239,7 +245,7 @@ function DefaultLayout({
/>
) : null}
<SignoutModal />
{isChallenge && !showCodeAlly && (
{isChallenge && !showCodeAlly && !examInProgress && (
<div className='breadcrumbs-demo'>
<BreadCrumb
block={block as string}
@@ -0,0 +1,9 @@
---
title: Introduction to the Example Certification Exam
block: example-certification-exam
superBlock: example-certification
---
## Introduction to the Example Certification Exam
Earn the Example Certification
@@ -0,0 +1,9 @@
---
title: Example Certification
superBlock: example-certification
certification: example-certification
---
## Introduction to Example Certification
Official Example Certification
+2
View File
@@ -22,6 +22,8 @@ export const actionTypes = createTypes(
'tryToShowDonationModal',
'executeGA',
'showCodeAlly',
'startExam',
'stopExam',
'submitComplete',
'updateComplete',
'updateFailed',
+3
View File
@@ -95,5 +95,8 @@ export const hideCodeAlly = createAction(actionTypes.hideCodeAlly);
export const showCodeAlly = createAction(actionTypes.showCodeAlly);
export const tryToShowCodeAlly = createAction(actionTypes.tryToShowCodeAlly);
export const startExam = createAction(actionTypes.startExam);
export const stopExam = createAction(actionTypes.stopExam);
export const closeSignoutModal = createAction(actionTypes.closeSignoutModal);
export const openSignoutModal = createAction(actionTypes.openSignoutModal);
+13
View File
@@ -47,6 +47,7 @@ const initialState = {
canRequestProgressDonation: true,
completionCount: 0,
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
examInProgress: false,
showCert: {},
showCertFetchState: {
...defaultFetchState
@@ -363,6 +364,18 @@ export const reducer = handleActions(
showCodeAlly: true
};
},
[actionTypes.startExam]: state => {
return {
...state,
examInProgress: true
};
},
[actionTypes.stopExam]: state => {
return {
...state,
examInProgress: false
};
},
[challengeTypes.challengeMounted]: (state, { payload }) => ({
...state,
currentChallengeId: payload
+4
View File
@@ -65,6 +65,10 @@ export const showCodeAllySelector = state => {
return state[MainApp].showCodeAlly;
};
export const examInProgressSelector = state => {
return state[MainApp].examInProgress;
};
export const userByNameSelector = username => state => {
const { user } = state[MainApp];
// return initial state empty user empty object instead of empty
+21 -6
View File
@@ -27,6 +27,7 @@ const machineLearningPyBase =
'/learn/machine-learning-with-python/machine-learning-with-python-projects';
const collegeAlgebraPyBase = '/learn/college-algebra-with-python';
const takeHomeBase = '/learn/coding-interview-prep/take-home-projects';
const exampleCertBase = '/learn/example-certification';
const legacyFrontEndBase = feLibsBase;
const legacyFrontEndResponsiveBase = responsiveWebBase;
const legacyFrontEndTakeHomeBase = takeHomeBase;
@@ -757,7 +758,23 @@ const certMap = [
]
}
] as const;
const upcomingCertMap = [] as const;
const upcomingCertMap = [
{
id: '64514fda6c245de4d11eb7bb',
title: 'Example Certification',
certSlug: 'example-certification-v8',
flag: 'isExampleCertV8',
projects: [
{
id: '645147516c245de4d11eb7ba',
title: 'Certification Exam',
link: `${exampleCertBase}/example-certification-exam`,
certSlug: 'example-certification-v8'
}
]
}
] as const;
function getResponsiveWebDesignPath(project: string) {
return `${responsiveWeb22Base}/${project}-project/${project}`;
@@ -773,11 +790,9 @@ function getJavaScriptAlgoPath(project: string) {
: `${jsAlgoBase}/${project}`;
}
const certMapWithoutFullStack = [
...upcomingCertMap,
...legacyCertMap,
...certMap
] as const;
const certMapWithoutFullStack = showUpcomingChanges
? [...upcomingCertMap, ...legacyCertMap, ...certMap]
: ([...legacyCertMap, ...certMap] as const);
const fullCertMap = [...certMapWithoutFullStack, legacyFullStack] as const;
@@ -0,0 +1,109 @@
import { Button } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import GreenPass from '../../../../assets/icons/green-pass';
import Fail from '../../../../assets/icons/fail';
import Spacer from '../../../../components/helpers/spacer';
import { submitChallenge } from '../../redux/actions';
import { examResultsSelector } from '../../redux/selectors';
interface ExamResultQuestion {
question: string;
answer: string;
correct: boolean;
}
interface ExamResults {
timeInSeconds: number;
results: ExamResultQuestion[];
}
interface ExamResultsProps {
examResults: ExamResults;
submitExamResults: () => void;
title: string;
submitChallenge: () => void;
}
const mapStateToProps = createSelector(
examResultsSelector,
(examResults: ExamResults) => ({
examResults
})
);
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
submitChallenge
},
dispatch
);
function ExamResults({
examResults: { timeInSeconds, results },
title,
submitExamResults
}: ExamResultsProps): JSX.Element {
const { t } = useTranslation();
const correctAnswers = results.filter(r => r.correct);
const correctPercent = (correctAnswers.length / results.length) * 100;
return (
<div className='exam-wrapper'>
<div className='exam-header'>
<div>{title} Results</div>
</div>
<hr />
<Spacer size='medium' />
<div className='exam-results'>
<div>Time: {timeInSeconds}</div>
<div>
{correctAnswers.length} of {results.length} correct answers |{' '}
{correctPercent}%
</div>
<Spacer size='medium' />
{results.map((result, index) => (
<>
<div className='exam-result' key={index}>
<div className='exam-result-icon'>
{result.correct ? <GreenPass /> : <Fail />}
</div>
<div className='exam-result-questions'>
<div className='exam-result-question-label'>
Question {index + 1}
</div>
<div className='exam-result-question'>{result.question}</div>
<div className='exam-result-answer-label'>Your Answer:</div>
<div className='exam-result-answer'>{result.answer}</div>
</div>
</div>
<Spacer size='medium' />
</>
))}
</div>
<Spacer size='medium' />
<div className='exam-buttons'>
<Button
block={true}
className='exam-button'
bsStyle='primary'
data-cy='submit-exam-results'
onClick={submitExamResults}
>
{t('buttons.submit-exam-results')}
</Button>
</div>
</div>
);
}
ExamResults.displayName = 'ExamResults';
export default connect(mapStateToProps, mapDispatchToProps)(ExamResults);
@@ -0,0 +1,84 @@
// Package Utilities
import { Button, Modal } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { createSelector } from 'reselect';
// Local Utilities
import { closeModal } from '../../redux/actions';
import { isFinishExamModalOpenSelector } from '../../redux/selectors';
// Types
interface FinishExamModalProps {
closeFinishExamModal: () => void;
isFinishExamModalOpen: boolean;
finishExam: () => void;
}
// Redux Setup
const mapStateToProps = createSelector(
isFinishExamModalOpenSelector,
(isFinishExamModalOpen: boolean) => ({
isFinishExamModalOpen
})
);
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
closeFinishExamModal: () => closeModal('finishExam')
},
dispatch
);
// Component
function FinishExamModal({
closeFinishExamModal,
isFinishExamModalOpen,
finishExam
}: FinishExamModalProps): JSX.Element {
return (
<Modal
animation={false}
dialogClassName='finish-exam-modal'
keyboard={true}
onHide={closeFinishExamModal}
show={isFinishExamModalOpen}
>
<Modal.Header className='finish-exam-modal-header' closeButton={true}>
<Modal.Title className='text-center'>Finish Exam</Modal.Title>
</Modal.Header>
<Modal.Body className='reset-modal-body'>
<div className='text-center'>
Are you sure? You will not be able to change any answers. Your results
will be final.
</div>
</Modal.Body>
<Modal.Footer className='reset-modal-footer'>
<Button
data-cy='finish-exam-modal-confirm'
block={true}
bsSize='medium'
bsStyle='primary'
onClick={finishExam}
>
Yes, I am finished
</Button>
<Button
data-cy='finish-exam-modal-deny'
block={true}
bsSize='medium'
bsStyle='primary'
onClick={closeFinishExamModal}
>
No, I would like to continue the exam
</Button>
</Modal.Footer>
</Modal>
);
}
FinishExamModal.displayName = 'FinishExamModal';
export default connect(mapStateToProps, mapDispatchToProps)(FinishExamModal);
@@ -0,0 +1,83 @@
.exam-wrapper {
padding: 25px;
background-color: var(--primary-background);
border: 2px solid var(--tertiary-background);
}
.exam-header {
width: 100%;
display: flex;
justify-content: space-evenly;
}
.exam-questions {
padding: 0 30px;
}
.exam-answers {
display: flex;
flex-direction: column;
}
.exam-answer-label {
display: flex;
margin-bottom: 20px;
padding: 20px;
cursor: pointer;
font-weight: normal;
}
.exam-answer-input-hidden {
position: absolute;
left: -9999px;
}
.exam-answer-input-visible {
margin-inline-end: 15px;
position: relative;
top: 2px;
display: inline-block;
min-width: 20px;
min-height: 20px;
max-width: 20px;
max-height: 20px;
border-radius: 50%;
background-color: var(--secondary-background);
border: 2px solid var(--primary-color);
}
.exam-answer-input-selected {
width: 10px;
height: 10px;
position: absolute;
top: 50%;
left: 50%;
background-color: var(--primary-color);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.exam-buttons {
display: flex;
justify-content: center;
}
.exam-buttons .exam-button {
margin: 0 10px;
max-width: 40%;
}
.exam-result {
display: flex;
}
.exam-result-icon > svg {
width: 40px;
height: 40px;
}
.exam-result-questions {
display: flex;
flex-direction: column;
padding-inline-start: 30px;
}
@@ -0,0 +1,667 @@
// Package Utilities
import { Grid, Col, Row, Button } from '@freecodecamp/react-bootstrap';
import { graphql } from 'gatsby';
import React, { Component, RefObject } from 'react';
import Helmet from 'react-helmet';
import type { TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux';
import { createSelector } from 'reselect';
// Local Utilities
import Spacer from '../../../components/helpers/spacer';
import LearnLayout from '../../../components/layouts/learn';
import ChallengeTitle from '../components/challenge-title';
import PrismFormatted from '../components/prism-formatted';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import Hotkeys from '../components/hotkeys';
import { startExam, stopExam } from '../../../redux/actions';
import {
completedChallengesSelector,
partiallyCompletedChallengesSelector,
isSignedInSelector,
examInProgressSelector
} from '../../../redux/selectors';
import {
challengeMounted,
updateChallengeMeta,
openModal,
closeModal,
submitChallenge,
setExamResults,
updateSolutionFormValues
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
import { createFlashMessage } from '../../../components/Flash/redux';
import {
ChallengeNode,
ChallengeMeta,
CompletedChallenge
} from '../../../redux/prop-types';
import FinishExamModal from './components/finish-exam-modal';
import ExamResults from './components/exam-results';
import './exam.css';
// Redux
const mapStateToProps = createSelector(
completedChallengesSelector,
isChallengeCompletedSelector,
isSignedInSelector,
partiallyCompletedChallengesSelector,
examInProgressSelector,
(
completedChallenges: CompletedChallenge[],
isChallengeCompleted: boolean,
isSignedIn: boolean,
partiallyCompletedChallenges: CompletedChallenge[],
examInProgress: boolean
) => ({
completedChallenges,
isChallengeCompleted,
isSignedIn,
partiallyCompletedChallenges,
examInProgress
})
);
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
challengeMounted,
createFlashMessage,
openFinishExamModal: () => openModal('finishExam'),
closeFinishExamModal: () => closeModal('finishExam'),
startExam,
stopExam,
setExamResults,
submitChallenge,
updateChallengeMeta,
updateSolutionFormValues
},
dispatch
);
// Types
interface ShowExamProps {
challengeMounted: (arg0: string) => void;
completedChallenges: CompletedChallenge[];
createFlashMessage: typeof createFlashMessage;
data: { challengeNode: ChallengeNode };
examInProgress: boolean;
isChallengeCompleted: boolean;
isSignedIn: boolean;
openFinishExamModal: () => void;
closeFinishExamModal: () => void;
pageContext: {
challengeMeta: ChallengeMeta;
};
t: TFunction;
startExam: () => void;
stopExam: () => void;
submitChallenge: () => void;
setExamResults: (arg0: ExamResults) => void;
updateChallengeMeta: (arg0: ChallengeMeta) => void;
}
interface ShowExamState {
currentQuestionIndex: number;
examTimeInSeconds: number;
generatedExam: GeneratedExamQuestion[];
userExam: UserExamQuestion[];
showResults: boolean;
}
interface GeneratedExamQuestion {
question: string;
answers: string[];
}
interface UserExamQuestion {
question: string;
answer: string | null;
}
interface ExamResultQuestion {
question: string;
answer: string;
correct: boolean;
}
interface ExamResults {
timeInSeconds: number;
results: ExamResultQuestion[];
}
const examInDatabase = [
{
question: 'Which of the following is a programming language?',
wrongAnswers: ['Apple', 'Orange', 'Banana', 'Mango'],
correctAnswer: 'Python'
},
{
question: 'What does CSS stand for?',
wrongAnswers: [
'Computer Style Sheets',
'Complete Style Sheets',
'Cool Style Sheets',
'Creative Style Sheets'
],
correctAnswer: 'Cascading Style Sheets'
},
{
question: 'What is the extension for a JavaScript file?',
wrongAnswers: ['.txt', '.doc', '.html', '.css'],
correctAnswer: '.js'
},
{
question: 'What is the purpose of the "if" statement in programming?',
wrongAnswers: [
'To repeat a set of instructions',
'To define a function',
'To assign a value to a variable',
'To declare a loop'
],
correctAnswer: 'To check a condition'
},
{
question:
'What is the symbol used to represent addition in most programming languages?',
wrongAnswers: ['-', '*', '=', '%'],
correctAnswer: '+'
},
{
question: 'Which of the following is NOT a programming language?',
wrongAnswers: ['Java', 'Ruby', 'Swift', 'PHP'],
correctAnswer: 'Spanish'
}
];
Object.freeze(examInDatabase);
// TODO: move helper functions to utility file
// helper functions
function shuffleArray(
array: string[] | GeneratedExamQuestion[]
): string[] | GeneratedExamQuestion[] {
let currentIndex = array.length,
randomIndex;
while (currentIndex != 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[array[currentIndex], array[randomIndex]] = [
array[randomIndex],
array[currentIndex]
];
}
return array;
}
function formatSecondsToTime(s: number) {
const hourInSeconds = 60 * 60;
const minuteInSeconds = 60;
const h = Math.floor(s / hourInSeconds);
s -= h * hourInSeconds;
const minutes = Math.floor(s / minuteInSeconds);
s -= minutes * minuteInSeconds;
const mm = minutes < 10 && h >= 1 ? `0${minutes}` : minutes;
const seconds = s % 60;
const ss = seconds < 10 ? `0${seconds}` : seconds;
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
// TODO: generate exam on server
function generateExam(): GeneratedExamQuestion[] {
const NUMBER_OF_ANSWERS_PER_QUESTION = 4;
const NUMBER_OF_QUESTIONS_IN_EXAM = 5;
const generatedExam: GeneratedExamQuestion[] = [];
const examFromDatabase = Array.from(examInDatabase);
while (generatedExam.length < NUMBER_OF_QUESTIONS_IN_EXAM) {
const randomIndex = Math.floor(
Math.random() * (examFromDatabase.length - 1)
);
const randomQuestion = examFromDatabase.splice(randomIndex, 1)[0];
const wrongAnswers = randomQuestion.wrongAnswers;
const answers = [randomQuestion.correctAnswer];
while (answers.length < NUMBER_OF_ANSWERS_PER_QUESTION) {
const index = Math.floor(Math.random() * (wrongAnswers.length - 1));
const randomAnswer = wrongAnswers.splice(index, 1)[0];
answers.push(randomAnswer);
}
const newExamQuestion: GeneratedExamQuestion = {
question: randomQuestion.question,
answers: shuffleArray(answers) as string[]
};
generatedExam.push(newExamQuestion);
}
return shuffleArray(generatedExam) as GeneratedExamQuestion[];
}
/* const exampleGeneratedExam = [
{
"question": "What is the extension for a JavaScript file?",
"answers": ['.txt', '.js', '.html', '.css']
},
...rest_of_questions
]*/
/* const exampleUserExam = [
{
"question": "What is the extension for a JavaScript file?",
"answer": ".doc"
},
...rest_of_questions
]*/
/* const exampleExamResults = [
{
"question": "What is the extension for a JavaScript file?",
"answer": ".doc",
"correct": false
}
...rest_of_questions
]
*/
/* example item added to completedChalleges array
{
"id": "645147516c245de4d11eb7ba",
"completedDate": 1644532946064,
"exam": {
"completionTimeInSeconds": number,
"results": [
{
"question": "What is the extension for a JavaScript file?",
"answer": ".doc",
"correct": false
},
...rest_of_questions
]
}
}*/
const generatedExam = generateExam();
Object.freeze(generatedExam);
class ShowExam extends Component<ShowExamProps, ShowExamState> {
static displayName: string;
private _container: RefObject<HTMLElement> | undefined;
timerInterval!: NodeJS.Timeout;
constructor(props: ShowExamProps) {
super(props);
this.state = {
currentQuestionIndex: 0,
generatedExam: generatedExam,
examTimeInSeconds: 0,
userExam: [],
showResults: false
};
this.runExam = this.runExam.bind(this);
this.goToPreviousQuestion = this.goToPreviousQuestion.bind(this);
this.goToNextQuestion = this.goToNextQuestion.bind(this);
this.selectAnswer = this.selectAnswer.bind(this);
this.finishExam = this.finishExam.bind(this);
this.createExamResults = this.createExamResults.bind(this);
this.submitExamResults = this.submitExamResults.bind(this);
}
componentDidMount(): void {
const {
challengeMounted,
data: {
challengeNode: {
challenge: { challengeType, helpCategory, title }
}
},
pageContext: { challengeMeta },
updateChallengeMeta
} = this.props;
updateChallengeMeta({
...challengeMeta,
title,
challengeType,
helpCategory
});
challengeMounted(challengeMeta.id);
this._container?.current?.focus();
}
componentWillUnmount() {
this.props.stopExam();
clearInterval(this.timerInterval);
window.removeEventListener('beforeunload', this.stopWindowClose);
window.removeEventListener('unload', this.stopWindowClose);
window.removeEventListener('popstate', this.stopBrowserBack);
}
stopWindowClose = (event: Event) => {
event.preventDefault();
alert('stop!');
};
stopBrowserBack = (event: Event) => {
event.preventDefault();
window.history.forward();
alert('stop!');
};
runExam = () => {
// TODO: show loader
// TODO: fetch exam from server/database
const newExam = this.state.generatedExam.map(q => {
return { question: q.question, answer: null };
});
this.timerInterval = setInterval(() => {
this.setState({
examTimeInSeconds: this.state.examTimeInSeconds + 1
});
}, 1000);
this.setState(
{
userExam: newExam
},
this.props.startExam
);
window.addEventListener('beforeunload', this.stopWindowClose);
window.addEventListener('unload', this.stopWindowClose);
window.addEventListener('popstate', this.stopBrowserBack);
};
selectAnswer = (index: number, option: string): void => {
const newExam = Array.from(this.state.userExam);
newExam[index].answer = option;
this.setState({
userExam: newExam
});
};
goToPreviousQuestion = () => {
this.setState({
currentQuestionIndex: this.state.currentQuestionIndex - 1
});
};
goToNextQuestion = () => {
this.setState({
currentQuestionIndex: this.state.currentQuestionIndex + 1
});
};
// TODO: have server check the exam
createExamResults = () => {
// TODO: show loader
const { setExamResults } = this.props;
const { userExam, examTimeInSeconds } = this.state;
const results: ExamResultQuestion[] = [];
userExam.forEach(userQuestion => {
const questionInDb = examInDatabase.find(
dbQuestion => userQuestion.question === dbQuestion.question
);
const questionResult = {
question: userQuestion.question,
answer: userQuestion.answer,
correct: questionInDb?.correctAnswer === userQuestion.answer
};
results.push(questionResult as ExamResultQuestion);
});
const examResults = {
timeInSeconds: examTimeInSeconds,
results
};
setExamResults(examResults);
};
finishExam = () => {
clearInterval(this.timerInterval);
this.props.closeFinishExamModal();
this.createExamResults();
this.setState({
showResults: true
});
};
submitExamResults = () => {
window.removeEventListener('beforeunload', this.stopWindowClose);
window.removeEventListener('unload', this.stopWindowClose);
window.removeEventListener('popstate', this.stopBrowserBack);
this.props.submitChallenge();
this.props.stopExam();
};
render() {
const {
data: {
challengeNode: {
challenge: {
block,
description,
fields: { blockName },
instructions,
superBlock,
title,
translationPending
}
}
},
examInProgress,
isChallengeCompleted,
openFinishExamModal,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
},
t
} = this.props;
const {
examTimeInSeconds,
currentQuestionIndex,
generatedExam,
userExam,
showResults
} = this.state;
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)}: ${title}`;
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
const ariaLabel = t('aria.answer');
console.log(this.state);
return examInProgress ? (
<Grid>
<Row>
<Spacer size='medium' />
<Col md={10} mdOffset={1} sm={10} smOffset={1} xs={12}>
{showResults ? (
<ExamResults
title={title}
submitExamResults={this.submitExamResults}
/>
) : (
<div className='exam-wrapper'>
<div className='exam-header'>
<div>{title}</div>
<span>|</span>
<div> Time: {formatSecondsToTime(examTimeInSeconds)}</div>
<span>|</span>
<div>
Question {currentQuestionIndex + 1} of{' '}
{generatedExam.length}
</div>
</div>
<hr />
<Spacer size='medium' />
<div className='exam-questions'>
<div>{generatedExam[currentQuestionIndex].question}</div>
<Spacer size='large' />
<div className='exam-answers'>
{generatedExam[currentQuestionIndex].answers.map(
(option, answerIndex) => (
<label className='exam-answer-label' key={answerIndex}>
<input
aria-label={ariaLabel}
checked={
userExam[currentQuestionIndex].answer === option
}
className='exam-answer-input-hidden'
name='exam'
onChange={() =>
this.selectAnswer(currentQuestionIndex, option)
}
type='radio'
value={option}
/>{' '}
<span className='exam-answer-input-visible'>
{userExam[currentQuestionIndex].answer ===
option ? (
<span className='exam-answer-input-selected' />
) : null}
</span>
<PrismFormatted
className={'exam-answer'}
text={option}
/>
</label>
)
)}
</div>
</div>
<Spacer size='large' />
<div className='exam-buttons'>
<Button
block={true}
className='exam-button'
disabled={currentQuestionIndex <= 0}
bsStyle='primary'
data-cy='previous-exam-question'
onClick={this.goToPreviousQuestion}
>
{t('buttons.previous-question')}
</Button>
{currentQuestionIndex === generatedExam.length - 1 ? (
<Button
block={true}
disabled={!userExam[currentQuestionIndex].answer}
className='exam-button'
bsStyle='primary'
data-cy='finish-exam'
onClick={openFinishExamModal}
>
{t('buttons.finish-exam')}
</Button>
) : (
<Button
block={true}
disabled={!userExam[currentQuestionIndex].answer}
className='exam-button'
bsStyle='primary'
data-cy='next-exam-question'
onClick={this.goToNextQuestion}
>
{t('buttons.next-question')}
</Button>
)}
</div>
</div>
)}
</Col>
<FinishExamModal finishExam={this.finishExam} />
</Row>
</Grid>
) : (
<Hotkeys
innerRef={this._container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet title={windowTitle} />
<Grid>
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Spacer size='medium' />
<PrismFormatted text={description} />
<Spacer size='medium' />
<PrismFormatted text={instructions} />
<Button
block={true}
bsStyle='primary'
data-cy='start-exam'
onClick={this.runExam}
>
{t('buttons.click-start-exam')}
</Button>
</Col>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Grid>
</LearnLayout>
</Hotkeys>
);
}
}
ShowExam.displayName = 'ShowExam';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(ShowExam));
// GraphQL
export const query = graphql`
query ExamChallenge($slug: String!) {
challengeNode(challenge: { fields: { slug: { eq: $slug } } }) {
challenge {
challengeType
description
fields {
blockName
}
helpCategory
id
instructions
superBlock
title
translationPending
}
}
}
`;
@@ -31,6 +31,7 @@ export const actionTypes = createTypes(
'closeModal',
'openModal',
'setIsAdvancing',
'setExamResults',
'previewMounted',
'projectPreviewMounted',
'storePortalWindow',
@@ -50,6 +50,8 @@ export const storedCodeFound = createAction(actionTypes.storedCodeFound);
export const noStoredCodeFound = createAction(actionTypes.noStoredCodeFound);
export const saveEditorContent = createAction(actionTypes.saveEditorContent);
export const setIsAdvancing = createAction(actionTypes.setIsAdvancing);
export const setExamResults = createAction(actionTypes.setExamResults);
export const closeModal = createAction(actionTypes.closeModal);
export const openModal = createAction(actionTypes.openModal);
@@ -35,6 +35,7 @@ import {
challengeFilesSelector,
challengeMetaSelector,
challengeTestsSelector,
examResultsSelector,
projectFormValuesSelector,
isBlockNewlyCompletedSelector
} from './selectors';
@@ -159,9 +160,27 @@ const submitters = {
tests: submitModern,
backend: submitBackendChallenge,
'project.frontEnd': submitProject,
'project.backEnd': submitProject
'project.backEnd': submitProject,
exam: submitExam
};
function submitExam(type, state) {
// TODO: verify shape of examResults?
if (type === actionTypes.submitChallenge) {
const { id } = challengeMetaSelector(state);
const examResults = examResultsSelector(state);
const { username } = userSelector(state);
const challengeInfo = { id, examResults };
const update = {
endpoint: '/exam-challenge-completed',
payload: challengeInfo
};
return postChallenge(update, username);
}
return empty();
}
export default function completionEpic(action$, state$) {
return action$.pipe(
ofType(actionTypes.submitChallenge),
@@ -27,6 +27,10 @@ const initialState = {
},
challengeTests: [],
consoleOut: [],
examResults: {
timeInSeconds: 0,
results: []
},
hasCompletedBlock: false,
isBuildEnabled: true,
isResetting: false,
@@ -36,6 +40,7 @@ const initialState = {
help: false,
video: false,
reset: false,
finishExam: false,
projectPreview: false,
shortcuts: false
},
@@ -189,6 +194,10 @@ export const reducer = handleActions(
...state,
isAdvancing: payload
}),
[actionTypes.setExamResults]: (state, { payload }) => ({
...state,
examResults: payload
}),
[actionTypes.closeModal]: (state, { payload }) => ({
...state,
modal: {
@@ -28,6 +28,8 @@ export const isCompletionModalOpenSelector = state =>
export const isHelpModalOpenSelector = state => state[ns].modal.help;
export const isVideoModalOpenSelector = state => state[ns].modal.video;
export const isResetModalOpenSelector = state => state[ns].modal.reset;
export const isFinishExamModalOpenSelector = state =>
state[ns].modal.finishExam;
export const isProjectPreviewModalOpenSelector = state =>
state[ns].modal.projectPreview;
export const isShortcutsModalOpenSelector = state => state[ns].modal.shortcuts;
@@ -42,6 +44,7 @@ export const isAdvancingToChallengeSelector = state => state[ns].isAdvancing;
export const portalDocumentSelector = state => state[ns].portalWindow?.document;
export const portalWindowSelector = state => state[ns].portalWindow;
export const examResultsSelector = state => state[ns].examResults;
export const challengeDataSelector = state => {
const { challengeType } = challengeMetaSelector(state);
let challengeData = { challengeType };
+10 -4
View File
@@ -16,6 +16,7 @@ const codeAllyCert = 13;
const multifileCertProject = 14;
const theOdinProject = 15;
const colab = 16;
const exam = 17;
// individual exports
exports.backend = backend;
@@ -24,6 +25,7 @@ exports.backEndProject = backEndProject;
exports.pythonProject = pythonProject;
exports.codeAllyCert = codeAllyCert;
exports.colab = colab;
exports.exam = exam;
exports.challengeTypes = {
html,
@@ -43,7 +45,8 @@ exports.challengeTypes = {
codeAllyCert,
multifileCertProject,
theOdinProject,
colab
colab,
exam
};
exports.isFinalProject = challengeType => {
@@ -55,7 +58,8 @@ exports.isFinalProject = challengeType => {
challengeType === jsProject ||
challengeType === pythonProject ||
challengeType === codeAllyCert ||
challengeType === multifileCertProject
challengeType === multifileCertProject ||
challengeType === exam
);
};
@@ -82,7 +86,8 @@ exports.viewTypes = {
[codeAllyCert]: 'codeAlly',
[multifileCertProject]: 'classic',
[theOdinProject]: 'odin',
[colab]: 'frontend'
[colab]: 'frontend',
[exam]: 'exam'
};
// determine the type of submit function to use for the challenge on completion
@@ -106,5 +111,6 @@ exports.submitTypes = {
[codeAllyCert]: 'project.frontEnd',
[multifileCertProject]: 'tests',
[theOdinProject]: 'tests',
[colab]: 'project.backEnd'
[colab]: 'project.backEnd',
[exam]: 'exam'
};
@@ -37,6 +37,11 @@ const odin = path.resolve(
'../../src/templates/Challenges/odin/show.tsx'
);
const exam = path.resolve(
__dirname,
'../../src/templates/Challenges/exam/show.tsx'
);
const views = {
backend,
classic,
@@ -44,7 +49,8 @@ const views = {
frontend,
video,
codeAlly,
odin
odin,
exam
// quiz: Quiz
};
+2 -1
View File
@@ -35,7 +35,8 @@ export enum SuperBlocks {
CodingInterviewPrep = 'coding-interview-prep',
TheOdinProject = 'the-odin-project',
ProjectEuler = 'project-euler',
CollegeAlgebraPy = 'college-algebra-with-python'
CollegeAlgebraPy = 'college-algebra-with-python',
ExampleCertification = 'example-certification'
}
export const certIds = {
+4 -1
View File
@@ -153,6 +153,7 @@ describe("'superBlockOrder' helper functions", () => {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification,
SuperBlocks.RespWebDesign
];
expect(learnSuperBlocks).toStrictEqual(test);
@@ -180,6 +181,7 @@ describe("'superBlockOrder' helper functions", () => {
showNewCurriculum: 'true',
showUpcomingChanges: 'true'
});
console.log(notAuditedSuperBlocks);
const test = [
SuperBlocks.RespWebDesignNew,
SuperBlocks.DataVis,
@@ -194,7 +196,8 @@ describe("'superBlockOrder' helper functions", () => {
SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
];
expect(notAuditedSuperBlocks).toStrictEqual(test);
expect(notAuditedSuperBlocks.length).toEqual(test.length);
+26 -11
View File
@@ -81,7 +81,8 @@ export const defaultSuperBlockOrder: SuperBlocks[] = [
SuperBlocks.CollegeAlgebraPy,
SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
];
/*
@@ -132,7 +133,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: [SuperBlocks.RespWebDesign]
},
@@ -187,7 +189,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -236,7 +239,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -285,7 +289,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -334,7 +339,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -380,7 +386,10 @@ export const superBlockOrder: SuperBlockOrder = {
[TranslationStates.NotAudited]: {
[SuperBlockStates.Current]: [],
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [SuperBlocks.JsAlgoDataStructNew],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
}
@@ -426,7 +435,10 @@ export const superBlockOrder: SuperBlockOrder = {
SuperBlocks.ProjectEuler
],
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [SuperBlocks.JsAlgoDataStructNew],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
}
@@ -475,7 +487,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -525,7 +538,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -574,7 +588,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject
SuperBlocks.TheOdinProject,
SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: [SuperBlocks.RespWebDesign]
}
@@ -0,0 +1,16 @@
{
"name": "Example Certification Exam",
"isUpcomingChange": true,
"dashedName": "example-certification-exam",
"helpCategory": "HTML-CSS",
"order": 0,
"time": "2 hours",
"template": "",
"required": [],
"superBlock": "example-certification",
"challengeOrder": [
[
"645147516c245de4d11eb7ba",
"Certification Exam"
]
]}
@@ -0,0 +1,9 @@
---
id: 64514fda6c245de4d11eb7bb
title: Example Certification
certification: example-certification
challengeType: 7
isPrivate: true
tests:
- id: 645147516c245de4d11eb7ba
title: Certification Exam
@@ -0,0 +1,20 @@
---
id: 645147516c245de4d11eb7ba
title: Certification Exam
challengeType: 17
dashedName: certification-exam
---
# --description--
Here are some rules:
- click start
# --instructions--
# --hints--
# --seed--
# --solutions--
@@ -4,5 +4,8 @@
"7e3lpb": "Redux:",
"b0atz9": "<h1>Hello World</h1>\n\n<h2>CatPhotoApp</h2>\n\n<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>",
"b1x7w0": "import React from 'react'\nimport ReactDOM from 'react-dom'\nimport { Provider, connect } from 'react-redux'\nimport { createStore, combineReducers, applyMiddleware } from 'redux'\nimport thunk from 'redux-thunk'\n\nimport rootReducer from './redux/reducers'\nimport App from './components/App'\n\nconst store = createStore(\n rootReducer,\n applyMiddleware(thunk)\n);\n\nReactDOM.render(\n <Provider store={store}>\n <App/>\n </Provider>,\n document.getElementById('root')\n);",
"4143lg": "TODO: Add link to cat photos"
"4143lg": "TODO: Add link to cat photos",
"3q1p2l": "console.log(spreadsheetFunctions[\"random\"](1, 1000) === spreadsheetFunctions[\"random\"](1, 1000))",
"gq2zsq": "window.onload();",
"3xhkhx": "highPrecedence(\"2*2\");"
}
@@ -98,6 +98,8 @@
"bheu99": "This will hold the set",
"x1djjr": "Use console.clear() on the next line to clear the browser console.",
"22ta95": "Use console.log() to print the output variable.",
"owgrP6": "Use the classes ABOVE this line",
"oszrtn": "Use the classes BELOW this line",
"w43c7l": "Using s = [2, 5, 7] would be invalid",
"pgckoj": "Variable assignments",
"2xiqvv": "Variable declarations",
+1 -1
View File
@@ -28,7 +28,7 @@ const schema = Joi.object()
challengeOrder: Joi.number(),
removeComments: Joi.bool(),
certification: Joi.string().regex(slugRE),
challengeType: Joi.number().min(0).max(15).required(),
challengeType: Joi.number().min(0).max(17).required(),
checksum: Joi.number(),
// TODO: require this only for normal challenges, not certs
dashedName: Joi.string().regex(slugRE),
+2 -1
View File
@@ -123,7 +123,8 @@ const directoryToSuperblock = {
'2022/javascript-algorithms-and-data-structures',
'16-the-odin-project': 'the-odin-project',
'17-college-algebra-with-python': 'college-algebra-with-python',
'18-project-euler': 'project-euler'
'18-project-euler': 'project-euler',
'99-example-certification': 'example-certification'
};
function getSuperBlockFromDir(dir) {
+7 -5
View File
@@ -44,7 +44,8 @@ const upcomingTest = {
[SuperBlocks.ProjectEuler]: 13,
[SuperBlocks.JsAlgoDataStructNew]: 14,
[SuperBlocks.TheOdinProject]: 15,
[SuperBlocks.RespWebDesign]: 16
[SuperBlocks.ExampleCertification]: 16,
[SuperBlocks.RespWebDesign]: 17
};
const espanolTest = {
@@ -153,7 +154,7 @@ describe('getSuperOrder', () => {
if (process.env.SHOW_UPCOMING_CHANGES !== 'true') {
expect.assertions(15);
} else {
expect.assertions(17);
expect.assertions(18);
}
expect(getSuperOrder(SuperBlocks.RespWebDesignNew)).toBe(0);
@@ -174,7 +175,8 @@ describe('getSuperOrder', () => {
if (process.env.SHOW_UPCOMING_CHANGES === 'true') {
expect(getSuperOrder(SuperBlocks.JsAlgoDataStructNew)).toBe(14);
expect(getSuperOrder(SuperBlocks.TheOdinProject)).toBe(15);
expect(getSuperOrder(SuperBlocks.RespWebDesign)).toBe(16);
expect(getSuperOrder(SuperBlocks.ExampleCertification)).toBe(16);
expect(getSuperOrder(SuperBlocks.RespWebDesign)).toBe(17);
} else {
expect(getSuperOrder(SuperBlocks.RespWebDesign)).toBe(14);
}
@@ -187,7 +189,7 @@ describe('getSuperBlockFromPath', () => {
);
it('handles all the directories in ./challenges/english', () => {
expect.assertions(18);
expect.assertions(19);
for (const directory of directories) {
expect(() => getSuperBlockFromDir(directory)).not.toThrow();
@@ -195,7 +197,7 @@ describe('getSuperBlockFromPath', () => {
});
it("returns valid superblocks (or 'certifications') for all valid arguments", () => {
expect.assertions(18);
expect.assertions(19);
const superBlockPaths = directories.filter(x => x !== '00-certifications');
+2 -1
View File
@@ -45,7 +45,8 @@ const superBlockFolderMap = {
'15-javascript-algorithms-and-data-structures-22',
'the-odin-project': '16-the-odin-project',
'college-algebra-with-python': '17-college-algebra-with-python',
'project-euler': '18-project-euler'
'project-euler': '18-project-euler',
'example-certification': '99-example-certification'
};
// These blocks are in the incorrect superblock. They should be moved but, for
+2 -1
View File
@@ -20,7 +20,8 @@ export function getSuperBlockSubPath(superBlock: SuperBlocks): string {
'15-javascript-algorithms-and-data-structures-22',
[SuperBlocks.TheOdinProject]: '16-the-odin-project',
[SuperBlocks.CollegeAlgebraPy]: '17-college-algebra-with-python',
[SuperBlocks.ProjectEuler]: '18-project-euler'
[SuperBlocks.ProjectEuler]: '18-project-euler',
[SuperBlocks.ExampleCertification]: '99-example-certification'
};
return pathMap[superBlock];
}
@@ -87,7 +87,8 @@ if (envData.clientLocale == 'english' && !envData.showUpcomingChanges) {
const isUpcoming = [
'2022/javascript-algorithms-and-data-structures',
'college-algebra-with-python',
'the-odin-project'
'the-odin-project',
'example-certification'
];
// TODO: this is a hack, we should have a single source of truth for the
+5
View File
@@ -1,9 +1,14 @@
const { getAuditedSuperBlocks } = require('../config/superblock-order');
const { Languages } = require('../config/i18n');
function isAuditedCert(language, superblock) {
if (!language || !superblock)
throw Error('Both arguments must be provided for auditing');
if (language === Languages.English) {
return true;
}
const auditedSuperBlocks = getAuditedSuperBlocks({ language });
return auditedSuperBlocks.includes(superblock);
}