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:
@@ -351,10 +351,27 @@ type Quiz {
|
|||||||
questions: [QuizQuestion]
|
questions: [QuizQuestion]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuizAudio {
|
||||||
|
filename: String
|
||||||
|
startTimestamp: Float
|
||||||
|
finishTimestamp: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuizTranscriptLine {
|
||||||
|
character: String
|
||||||
|
text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuizAudioData {
|
||||||
|
audio: QuizAudio
|
||||||
|
transcript: [QuizTranscriptLine]
|
||||||
|
}
|
||||||
|
|
||||||
type QuizQuestion {
|
type QuizQuestion {
|
||||||
text: String
|
text: String
|
||||||
distractors: [String]
|
distractors: [String]
|
||||||
answer: String
|
answer: String
|
||||||
|
audioData: QuizAudioData
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequiredResource {
|
type RequiredResource {
|
||||||
|
|||||||
@@ -347,10 +347,27 @@ type Quiz = {
|
|||||||
questions: QuizQuestion[];
|
questions: QuizQuestion[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QuizAudio = {
|
||||||
|
filename: string;
|
||||||
|
startTimestamp?: number | null;
|
||||||
|
finishTimestamp?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuizTranscriptLine = {
|
||||||
|
character: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuizAudioData = {
|
||||||
|
audio: QuizAudio;
|
||||||
|
transcript: QuizTranscriptLine[];
|
||||||
|
};
|
||||||
|
|
||||||
type QuizQuestion = {
|
type QuizQuestion = {
|
||||||
text: string;
|
text: string;
|
||||||
distractors: string[];
|
distractors: string[];
|
||||||
answer: string;
|
answer: string;
|
||||||
|
audioData?: QuizAudioData | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CertificateNode = {
|
export type CertificateNode = {
|
||||||
|
|||||||
@@ -25,3 +25,9 @@
|
|||||||
.quiz-answer-label:has(ruby) {
|
.quiz-answer-label:has(ruby) {
|
||||||
line-height: 1.7rem;
|
line-height: 1.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Override global button:hover for the play/pause button in the Quiz component */
|
||||||
|
.quiz-challenge-container button:has(svg.svg-inline--fa):hover {
|
||||||
|
background-color: unset;
|
||||||
|
color: unset;
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,16 +166,36 @@ const ShowQuiz = ({
|
|||||||
value: 4
|
value: 4
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const allAnswers = shuffleArray([...distractors, answer]);
|
||||||
|
|
||||||
|
const audioData = question.audioData?.audio?.filename
|
||||||
|
? {
|
||||||
|
audioUrl: `https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/${question.audioData.audio.filename}`,
|
||||||
|
audioStartTime:
|
||||||
|
question.audioData.audio.startTimestamp ?? undefined,
|
||||||
|
audioFinishTime:
|
||||||
|
question.audioData.audio.finishTimestamp ?? undefined,
|
||||||
|
transcript: question.audioData.transcript.length
|
||||||
|
? question.audioData.transcript
|
||||||
|
.map(line => `<p><b>${line.character}</b>: ${line.text}</p>`)
|
||||||
|
.join('')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const questionData = {
|
||||||
question: (
|
question: (
|
||||||
<PrismFormatted
|
<PrismFormatted
|
||||||
className='quiz-question-label'
|
className='quiz-question-label'
|
||||||
text={question.text}
|
text={question.text}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
answers: shuffleArray([...distractors, answer]),
|
answers: allAnswers,
|
||||||
correctAnswer: answer.value
|
correctAnswer: answer.value,
|
||||||
|
...audioData
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return questionData;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -400,6 +420,17 @@ export const query = graphql`
|
|||||||
distractors
|
distractors
|
||||||
text
|
text
|
||||||
answer
|
answer
|
||||||
|
audioData {
|
||||||
|
audio {
|
||||||
|
filename
|
||||||
|
startTimestamp
|
||||||
|
finishTimestamp
|
||||||
|
}
|
||||||
|
transcript {
|
||||||
|
character
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tests {
|
tests {
|
||||||
|
|||||||
+19
-1
@@ -44,7 +44,25 @@ Which of the following is NOT a greeting?
|
|||||||
|
|
||||||
#### --text--
|
#### --text--
|
||||||
|
|
||||||
Which is the correct reply to this sentence: `Hello! You're the new graphic designer, right?`
|
Listen to the audio and select the most appropriate answer.
|
||||||
|
|
||||||
|
#### --audio--
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio": {
|
||||||
|
"filename": "1.1-1.mp3",
|
||||||
|
"startTimestamp": 0,
|
||||||
|
"finishTimestamp": 4.2
|
||||||
|
},
|
||||||
|
"transcript": [
|
||||||
|
{
|
||||||
|
"character": "Maria",
|
||||||
|
"text": "Hello. You're the new graphic designer, right? I'm Maria, the team lead."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### --distractors--
|
#### --distractors--
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"distractors": ["<p>Wrong 1</p>", "<p>Wrong 2</p>", "<p>Wrong 3</p>"],
|
||||||
|
"text": "<p>What does the audio say?</p>",
|
||||||
|
"answer": "<p>Correct answer</p>",
|
||||||
|
"audioData": {
|
||||||
|
"audio": {
|
||||||
|
"filename": "test-audio.mp3",
|
||||||
|
"startTimestamp": 0,
|
||||||
|
"finishTimestamp": 2
|
||||||
|
},
|
||||||
|
"transcript": [
|
||||||
|
{
|
||||||
|
"character": "Speaker",
|
||||||
|
"text": "Hello world"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -289,3 +289,46 @@ test.describe('Quiz challenge', () => {
|
|||||||
await page.close({ runBeforeUnload: true });
|
await page.close({ runBeforeUnload: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Quiz with audio question', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const fixturePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'fixtures',
|
||||||
|
'quiz-audio-fixture.json'
|
||||||
|
);
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as Quiz[];
|
||||||
|
|
||||||
|
// Intercept the exact page-data.json for the quiz and inject the fixture
|
||||||
|
await page.route(`**/page-data${quizPath}/page-data.json`, async route => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
const pageData = JSON.parse(body) as PageData;
|
||||||
|
pageData.result.data.challengeNode.challenge.quizzes = fixture;
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(pageData)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(quizPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders audio player and transcript when question has audio', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await expect(page.getByRole('radiogroup')).toHaveCount(1);
|
||||||
|
|
||||||
|
const audio = page.locator('audio');
|
||||||
|
await expect(audio).toHaveCount(1);
|
||||||
|
await expect(audio).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/test-audio.mp3'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByText(/transcript/i).click();
|
||||||
|
await expect(page.getByText('Speaker: Hello world')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 text = toHtml(textNodes);
|
||||||
const distractors = getDistractors(distractorNodes);
|
const distractors = getDistractors(distractorNodes);
|
||||||
const answer = toHtml(answerNodes);
|
const answer = toHtml(answerNodes);
|
||||||
@@ -31,7 +31,52 @@ function plugin() {
|
|||||||
throw Error('--distractors-- are missing from quiz question');
|
throw Error('--distractors-- are missing from quiz question');
|
||||||
if (!answer) throw Error('--answer-- is 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) {
|
if (quizzesNodes.length > 0) {
|
||||||
@@ -60,9 +105,10 @@ function plugin() {
|
|||||||
const textNodes = getSection(questionTree, '--text--');
|
const textNodes = getSection(questionTree, '--text--');
|
||||||
const distractorNodes = getSection(questionTree, '--distractors--');
|
const distractorNodes = getSection(questionTree, '--distractors--');
|
||||||
const answerNodes = getSection(questionTree, '--answer--');
|
const answerNodes = getSection(questionTree, '--answer--');
|
||||||
|
const audioNodes = getSection(questionTree, '--audio--');
|
||||||
|
|
||||||
quizQuestions.push(
|
quizQuestions.push(
|
||||||
getQuestion(textNodes, distractorNodes, answerNodes)
|
getQuestion(textNodes, distractorNodes, answerNodes, audioNodes)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,46 @@ import addQuizzes from './add-quizzes';
|
|||||||
describe('add-quizzes plugin', () => {
|
describe('add-quizzes plugin', () => {
|
||||||
let mockQuizzesAST;
|
let mockQuizzesAST;
|
||||||
let chineseQuizzesAST;
|
let chineseQuizzesAST;
|
||||||
|
let quizzesWithAudioAST;
|
||||||
|
let quizzesWithAudioInvalidJsonAST;
|
||||||
|
let quizzesWithAudioMissingFilenameAST;
|
||||||
|
let quizzesWithAudioTranscriptNotArrayAST;
|
||||||
|
let quizzesWithAudioEmptyTranscriptAST;
|
||||||
|
let quizzesWithAudioMissingTranscriptAST;
|
||||||
|
let quizzesWithAudioTranscriptMissingCharacterAST;
|
||||||
|
let quizzesWithAudioTranscriptMissingTextAST;
|
||||||
|
let quizzesWithAudioNotJsonCodeBlockAST;
|
||||||
const plugin = addQuizzes();
|
const plugin = addQuizzes();
|
||||||
let file = { data: {} };
|
let file = { data: {} };
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mockQuizzesAST = await parseFixture('with-quizzes.md');
|
mockQuizzesAST = await parseFixture('with-quizzes.md');
|
||||||
chineseQuizzesAST = await parseFixture('with-chinese-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(() => {
|
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>'
|
'<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
|
// Level 4
|
||||||
'#### --answer--',
|
'#### --answer--',
|
||||||
|
'#### --audio--',
|
||||||
'#### --distractors--',
|
'#### --distractors--',
|
||||||
'#### --text--'
|
'#### --text--'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ const idToFilepath = new Map();
|
|||||||
// recently overwritten files
|
// recently overwritten files
|
||||||
const idToOverwrittenFile = new Map();
|
const idToOverwrittenFile = new Map();
|
||||||
|
|
||||||
|
exports.createSchemaCustomization = ({ actions }) => {
|
||||||
|
const { createTypes } = actions;
|
||||||
|
const typeDefs = `
|
||||||
|
type QuizQuestion {
|
||||||
|
text: String!
|
||||||
|
distractors: [String!]!
|
||||||
|
answer: String!
|
||||||
|
audioId: String
|
||||||
|
transcript: String
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
createTypes(typeDefs);
|
||||||
|
};
|
||||||
|
|
||||||
exports.sourceNodes = function sourceChallengesSourceNodes(
|
exports.sourceNodes = function sourceChallengesSourceNodes(
|
||||||
{ actions, reporter, createNodeId, createContentDigest },
|
{ actions, reporter, createNodeId, createContentDigest },
|
||||||
pluginOptions
|
pluginOptions
|
||||||
|
|||||||
Reference in New Issue
Block a user