mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client,challenge-parser): support audio and transcript in quiz questions (#65711)
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio but empty transcript
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio-with-empty-transcript.mp3"
|
||||
},
|
||||
"transcript": []
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
@@ -0,0 +1,42 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with invalid JSON audio
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio.mp3"
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "A",
|
||||
"text": "Hello"
|
||||
}
|
||||
]
|
||||
// missing comma
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
@@ -0,0 +1,39 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio missing filename
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "A",
|
||||
"text": "Hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
@@ -0,0 +1,35 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio but no transcript
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio-without-transcript.mp3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
@@ -0,0 +1,29 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio not in JSON code block
|
||||
|
||||
#### --audio--
|
||||
|
||||
Some plain text audio data
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio transcript line missing character
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio.mp3"
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"text": "Hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio transcript line missing text
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio.mp3"
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
@@ -0,0 +1,36 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Question with audio transcript not array
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio.mp3"
|
||||
},
|
||||
"transcript": "not an array"
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Distractor 1
|
||||
|
||||
---
|
||||
|
||||
Distractor 2
|
||||
|
||||
---
|
||||
|
||||
Distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Answer
|
||||
@@ -0,0 +1,107 @@
|
||||
# --quizzes--
|
||||
|
||||
## --quiz--
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Quiz 1, question 1 with audio timestamps
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio-with-timestamps.mp3",
|
||||
"startTimestamp": 1.5,
|
||||
"finishTimestamp": 3.8
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "Maria",
|
||||
"text": "Hello, how are you?"
|
||||
},
|
||||
{
|
||||
"character": "Tom",
|
||||
"text": "I'm doing well, thank you."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Quiz 1, question 1, distractor 1
|
||||
|
||||
---
|
||||
|
||||
Quiz 1, question 1, distractor 2
|
||||
|
||||
---
|
||||
|
||||
Quiz 1, question 1, distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Quiz 1, question 1, answer
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Quiz 1, question 2 with audio but no timestamps
|
||||
|
||||
#### --audio--
|
||||
|
||||
```json
|
||||
{
|
||||
"audio": {
|
||||
"filename": "audio-full-file.mp3"
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "Speaker",
|
||||
"text": "This is the full audio transcript."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Quiz 1, question 2, distractor 1
|
||||
|
||||
---
|
||||
|
||||
Quiz 1, question 2, distractor 2
|
||||
|
||||
---
|
||||
|
||||
Quiz 1, question 2, distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Quiz 1, question 2, answer
|
||||
|
||||
### --question--
|
||||
|
||||
#### --text--
|
||||
|
||||
Quiz 1, question 3 without audio
|
||||
|
||||
#### --distractors--
|
||||
|
||||
Quiz 1, question 3, distractor 1
|
||||
|
||||
---
|
||||
|
||||
Quiz 1, question 3, distractor 2
|
||||
|
||||
---
|
||||
|
||||
Quiz 1, question 3, distractor 3
|
||||
|
||||
#### --answer--
|
||||
|
||||
Quiz 1, question 3, answer
|
||||
@@ -21,7 +21,7 @@ function plugin() {
|
||||
});
|
||||
}
|
||||
|
||||
function getQuestion(textNodes, distractorNodes, answerNodes) {
|
||||
function getQuestion(textNodes, distractorNodes, answerNodes, audioNodes) {
|
||||
const text = toHtml(textNodes);
|
||||
const distractors = getDistractors(distractorNodes);
|
||||
const answer = toHtml(answerNodes);
|
||||
@@ -31,7 +31,52 @@ function plugin() {
|
||||
throw Error('--distractors-- are missing from quiz question');
|
||||
if (!answer) throw Error('--answer-- is missing from quiz question');
|
||||
|
||||
return { text, distractors, answer };
|
||||
const questionData = { text, distractors, answer };
|
||||
|
||||
// Extract audio data if present
|
||||
if (audioNodes.length > 0) {
|
||||
// Audio should be in a JSON code block
|
||||
if (audioNodes[0].type !== 'code' || audioNodes[0].lang !== 'json') {
|
||||
throw Error('--audio-- section must contain a ```json code block');
|
||||
}
|
||||
|
||||
try {
|
||||
const audioData = JSON.parse(audioNodes[0].value);
|
||||
|
||||
// Validate structure
|
||||
if (!audioData.audio || !audioData.audio.filename) {
|
||||
throw Error('--audio-- section must contain audio.filename');
|
||||
}
|
||||
|
||||
if (!audioData.transcript || !Array.isArray(audioData.transcript)) {
|
||||
throw Error(
|
||||
'--audio-- section must contain transcript as an array'
|
||||
);
|
||||
}
|
||||
|
||||
if (audioData.transcript.length === 0) {
|
||||
throw Error('--audio-- section transcript array cannot be empty');
|
||||
}
|
||||
|
||||
// Validate each transcript line
|
||||
audioData.transcript.forEach((line, index) => {
|
||||
if (!line.character || !line.text) {
|
||||
throw Error(
|
||||
`--audio-- transcript line ${index} must have character and text properties`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
questionData.audioData = audioData;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw Error('--audio-- section must contain valid JSON');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return questionData;
|
||||
}
|
||||
|
||||
if (quizzesNodes.length > 0) {
|
||||
@@ -60,9 +105,10 @@ function plugin() {
|
||||
const textNodes = getSection(questionTree, '--text--');
|
||||
const distractorNodes = getSection(questionTree, '--distractors--');
|
||||
const answerNodes = getSection(questionTree, '--answer--');
|
||||
const audioNodes = getSection(questionTree, '--audio--');
|
||||
|
||||
quizQuestions.push(
|
||||
getQuestion(textNodes, distractorNodes, answerNodes)
|
||||
getQuestion(textNodes, distractorNodes, answerNodes, audioNodes)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,46 @@ import addQuizzes from './add-quizzes';
|
||||
describe('add-quizzes plugin', () => {
|
||||
let mockQuizzesAST;
|
||||
let chineseQuizzesAST;
|
||||
let quizzesWithAudioAST;
|
||||
let quizzesWithAudioInvalidJsonAST;
|
||||
let quizzesWithAudioMissingFilenameAST;
|
||||
let quizzesWithAudioTranscriptNotArrayAST;
|
||||
let quizzesWithAudioEmptyTranscriptAST;
|
||||
let quizzesWithAudioMissingTranscriptAST;
|
||||
let quizzesWithAudioTranscriptMissingCharacterAST;
|
||||
let quizzesWithAudioTranscriptMissingTextAST;
|
||||
let quizzesWithAudioNotJsonCodeBlockAST;
|
||||
const plugin = addQuizzes();
|
||||
let file = { data: {} };
|
||||
|
||||
beforeAll(async () => {
|
||||
mockQuizzesAST = await parseFixture('with-quizzes.md');
|
||||
chineseQuizzesAST = await parseFixture('with-chinese-quizzes.md');
|
||||
quizzesWithAudioAST = await parseFixture('with-quizzes-audio.md');
|
||||
quizzesWithAudioInvalidJsonAST = await parseFixture(
|
||||
'with-quizzes-audio-invalid-json.md'
|
||||
);
|
||||
quizzesWithAudioMissingFilenameAST = await parseFixture(
|
||||
'with-quizzes-audio-missing-filename.md'
|
||||
);
|
||||
quizzesWithAudioTranscriptNotArrayAST = await parseFixture(
|
||||
'with-quizzes-audio-transcript-not-array.md'
|
||||
);
|
||||
quizzesWithAudioEmptyTranscriptAST = await parseFixture(
|
||||
'with-quizzes-audio-empty-transcript.md'
|
||||
);
|
||||
quizzesWithAudioMissingTranscriptAST = await parseFixture(
|
||||
'with-quizzes-audio-missing-transcript.md'
|
||||
);
|
||||
quizzesWithAudioTranscriptMissingCharacterAST = await parseFixture(
|
||||
'with-quizzes-audio-transcript-missing-character.md'
|
||||
);
|
||||
quizzesWithAudioTranscriptMissingTextAST = await parseFixture(
|
||||
'with-quizzes-audio-transcript-missing-text.md'
|
||||
);
|
||||
quizzesWithAudioNotJsonCodeBlockAST = await parseFixture(
|
||||
'with-quizzes-audio-not-json-code-block.md'
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -135,4 +169,111 @@ describe('add-quizzes plugin', () => {
|
||||
'<p>Quiz 1, question 3, answer with <span class="highlighted-text">zhōng wén</span></p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse audio sections in quiz questions', () => {
|
||||
plugin(quizzesWithAudioAST, file);
|
||||
const quizzes = file.data.quizzes;
|
||||
|
||||
expect(Array.isArray(quizzes)).toBe(true);
|
||||
expect(quizzes.length).toBe(1);
|
||||
|
||||
const firstQuiz = quizzes[0];
|
||||
const firstQuestion = firstQuiz.questions[0];
|
||||
const secondQuestion = firstQuiz.questions[1];
|
||||
const thirdQuestion = firstQuiz.questions[2];
|
||||
|
||||
// First question has audio with timestamps
|
||||
expect(firstQuestion).toHaveProperty('audioData');
|
||||
expect(firstQuestion.audioData.audio.filename).toBe(
|
||||
'audio-with-timestamps.mp3'
|
||||
);
|
||||
expect(firstQuestion.audioData.audio.startTimestamp).toBe(1.5);
|
||||
expect(firstQuestion.audioData.audio.finishTimestamp).toBe(3.8);
|
||||
expect(firstQuestion.audioData.transcript).toEqual([
|
||||
{
|
||||
character: 'Maria',
|
||||
text: 'Hello, how are you?'
|
||||
},
|
||||
{
|
||||
character: 'Tom',
|
||||
text: "I'm doing well, thank you."
|
||||
}
|
||||
]);
|
||||
expect(firstQuestion.text).toBe(
|
||||
'<p>Quiz 1, question 1 with audio timestamps</p>'
|
||||
);
|
||||
expect(firstQuestion.distractors.length).toBe(3);
|
||||
expect(firstQuestion.answer).toBe('<p>Quiz 1, question 1, answer</p>');
|
||||
|
||||
// Second question has audio without timestamps
|
||||
expect(secondQuestion).toHaveProperty('audioData');
|
||||
expect(secondQuestion.audioData.audio.filename).toBe('audio-full-file.mp3');
|
||||
expect(secondQuestion.audioData.audio.startTimestamp).toBeUndefined();
|
||||
expect(secondQuestion.audioData.audio.finishTimestamp).toBeUndefined();
|
||||
expect(secondQuestion.audioData.transcript).toEqual([
|
||||
{
|
||||
character: 'Speaker',
|
||||
text: 'This is the full audio transcript.'
|
||||
}
|
||||
]);
|
||||
expect(secondQuestion.text).toBe(
|
||||
'<p>Quiz 1, question 2 with audio but no timestamps</p>'
|
||||
);
|
||||
|
||||
// Third question has no audio
|
||||
expect(thirdQuestion.audioData).toBeUndefined();
|
||||
expect(thirdQuestion.text).toBe('<p>Quiz 1, question 3 without audio</p>');
|
||||
});
|
||||
|
||||
it('should throw error for audio section not in JSON code block', () => {
|
||||
expect(() => plugin(quizzesWithAudioNotJsonCodeBlockAST, file)).toThrow(
|
||||
'--audio-- section must contain a ```json code block'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON in audio section', () => {
|
||||
expect(() => plugin(quizzesWithAudioInvalidJsonAST, file)).toThrow(
|
||||
'--audio-- section must contain valid JSON'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing audio filename', () => {
|
||||
expect(() => plugin(quizzesWithAudioMissingFilenameAST, file)).toThrow(
|
||||
'--audio-- section must contain audio.filename'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for transcript not being an array', () => {
|
||||
expect(() => plugin(quizzesWithAudioTranscriptNotArrayAST, file)).toThrow(
|
||||
'--audio-- section must contain transcript as an array'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty transcript array', () => {
|
||||
expect(() => plugin(quizzesWithAudioEmptyTranscriptAST, file)).toThrow(
|
||||
'--audio-- section transcript array cannot be empty'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing transcript', () => {
|
||||
expect(() => plugin(quizzesWithAudioMissingTranscriptAST, file)).toThrow(
|
||||
'--audio-- section must contain transcript as an array'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for transcript line missing character', () => {
|
||||
expect(() =>
|
||||
plugin(quizzesWithAudioTranscriptMissingCharacterAST, file)
|
||||
).toThrow(
|
||||
'--audio-- transcript line 0 must have character and text properties'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for transcript line missing text', () => {
|
||||
expect(() =>
|
||||
plugin(quizzesWithAudioTranscriptMissingTextAST, file)
|
||||
).toThrow(
|
||||
'--audio-- transcript line 0 must have character and text properties'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ const VALID_MARKERS = [
|
||||
|
||||
// Level 4
|
||||
'#### --answer--',
|
||||
'#### --audio--',
|
||||
'#### --distractors--',
|
||||
'#### --text--'
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user