feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
801
ai-compliance-sdk/internal/training/interactive_video_test.go
Normal file
801
ai-compliance-sdk/internal/training/interactive_video_test.go
Normal file
@@ -0,0 +1,801 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// parseNarratorScript Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestParseNarratorScript_ValidJSON(t *testing.T) {
|
||||
input := `{
|
||||
"title": "DSGVO Grundlagen",
|
||||
"intro": "Hallo, ich bin Ihr AI Teacher.",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Einfuehrung",
|
||||
"narrator_text": "Willkommen zur Schulung ueber die DSGVO.",
|
||||
"bullet_points": ["Punkt 1", "Punkt 2"],
|
||||
"transition": "Bevor wir fortfahren...",
|
||||
"checkpoint": {
|
||||
"title": "Checkpoint 1",
|
||||
"questions": [
|
||||
{
|
||||
"question": "Was ist die DSGVO?",
|
||||
"options": ["EU-Verordnung", "Bundesgesetz", "Landesgesetz", "Internationale Konvention"],
|
||||
"correct_index": 0,
|
||||
"explanation": "Die DSGVO ist eine EU-Verordnung."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outro": "Vielen Dank fuer Ihre Aufmerksamkeit.",
|
||||
"total_duration_estimate": 600
|
||||
}`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if script.Title != "DSGVO Grundlagen" {
|
||||
t.Errorf("Expected title 'DSGVO Grundlagen', got '%s'", script.Title)
|
||||
}
|
||||
if script.Intro != "Hallo, ich bin Ihr AI Teacher." {
|
||||
t.Errorf("Expected intro text, got '%s'", script.Intro)
|
||||
}
|
||||
if len(script.Sections) != 1 {
|
||||
t.Fatalf("Expected 1 section, got %d", len(script.Sections))
|
||||
}
|
||||
if script.Sections[0].Heading != "Einfuehrung" {
|
||||
t.Errorf("Expected heading 'Einfuehrung', got '%s'", script.Sections[0].Heading)
|
||||
}
|
||||
if script.Sections[0].Checkpoint == nil {
|
||||
t.Fatal("Expected checkpoint, got nil")
|
||||
}
|
||||
if len(script.Sections[0].Checkpoint.Questions) != 1 {
|
||||
t.Fatalf("Expected 1 question, got %d", len(script.Sections[0].Checkpoint.Questions))
|
||||
}
|
||||
if script.Sections[0].Checkpoint.Questions[0].CorrectIndex != 0 {
|
||||
t.Errorf("Expected correct_index 0, got %d", script.Sections[0].Checkpoint.Questions[0].CorrectIndex)
|
||||
}
|
||||
if script.Outro != "Vielen Dank fuer Ihre Aufmerksamkeit." {
|
||||
t.Errorf("Expected outro text, got '%s'", script.Outro)
|
||||
}
|
||||
if script.TotalDurationEstimate != 600 {
|
||||
t.Errorf("Expected 600 seconds estimate, got %d", script.TotalDurationEstimate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_WithSurroundingText(t *testing.T) {
|
||||
input := `Here is the narrator script:
|
||||
{
|
||||
"title": "NIS-2 Schulung",
|
||||
"intro": "Willkommen",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Abschnitt 1",
|
||||
"narrator_text": "Text hier.",
|
||||
"bullet_points": ["BP1"],
|
||||
"transition": "Weiter"
|
||||
}
|
||||
],
|
||||
"outro": "Ende",
|
||||
"total_duration_estimate": 300
|
||||
}
|
||||
I hope this helps!`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if script.Title != "NIS-2 Schulung" {
|
||||
t.Errorf("Expected title 'NIS-2 Schulung', got '%s'", script.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_InvalidJSON(t *testing.T) {
|
||||
_, err := parseNarratorScript("not valid json")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_NoSections(t *testing.T) {
|
||||
input := `{"title": "Test", "intro": "Hi", "sections": [], "outro": "Bye", "total_duration_estimate": 0}`
|
||||
_, err := parseNarratorScript(input)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty sections")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_NoJSON(t *testing.T) {
|
||||
_, err := parseNarratorScript("Just plain text without any JSON")
|
||||
if err == nil {
|
||||
t.Error("Expected error when no JSON object found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_SectionWithoutCheckpoint(t *testing.T) {
|
||||
input := `{
|
||||
"title": "Test",
|
||||
"intro": "Hi",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Section 1",
|
||||
"narrator_text": "Some text",
|
||||
"bullet_points": ["P1"],
|
||||
"transition": "Next"
|
||||
}
|
||||
],
|
||||
"outro": "Bye",
|
||||
"total_duration_estimate": 180
|
||||
}`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if script.Sections[0].Checkpoint != nil {
|
||||
t.Error("Section without checkpoint definition should have nil Checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_MultipleSectionsWithCheckpoints(t *testing.T) {
|
||||
input := `{
|
||||
"title": "Multi-Section",
|
||||
"intro": "Start",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "S1",
|
||||
"narrator_text": "Text 1",
|
||||
"bullet_points": [],
|
||||
"transition": "T1",
|
||||
"checkpoint": {
|
||||
"title": "CP1",
|
||||
"questions": [
|
||||
{"question": "Q1?", "options": ["A", "B", "C", "D"], "correct_index": 0, "explanation": "E1"},
|
||||
{"question": "Q2?", "options": ["A", "B", "C", "D"], "correct_index": 1, "explanation": "E2"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"heading": "S2",
|
||||
"narrator_text": "Text 2",
|
||||
"bullet_points": ["BP"],
|
||||
"transition": "T2",
|
||||
"checkpoint": {
|
||||
"title": "CP2",
|
||||
"questions": [
|
||||
{"question": "Q3?", "options": ["A", "B", "C", "D"], "correct_index": 2, "explanation": "E3"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"heading": "S3",
|
||||
"narrator_text": "Text 3",
|
||||
"bullet_points": [],
|
||||
"transition": "T3"
|
||||
}
|
||||
],
|
||||
"outro": "End",
|
||||
"total_duration_estimate": 900
|
||||
}`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(script.Sections) != 3 {
|
||||
t.Fatalf("Expected 3 sections, got %d", len(script.Sections))
|
||||
}
|
||||
if script.Sections[0].Checkpoint == nil {
|
||||
t.Error("Section 0 should have a checkpoint")
|
||||
}
|
||||
if len(script.Sections[0].Checkpoint.Questions) != 2 {
|
||||
t.Errorf("Section 0 checkpoint should have 2 questions, got %d", len(script.Sections[0].Checkpoint.Questions))
|
||||
}
|
||||
if script.Sections[1].Checkpoint == nil {
|
||||
t.Error("Section 1 should have a checkpoint")
|
||||
}
|
||||
if script.Sections[2].Checkpoint != nil {
|
||||
t.Error("Section 2 should not have a checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// countCheckpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestCountCheckpoints_WithCheckpoints(t *testing.T) {
|
||||
script := &NarratorScript{
|
||||
Sections: []NarratorSection{
|
||||
{Checkpoint: &CheckpointDefinition{Title: "CP1"}},
|
||||
{Checkpoint: nil},
|
||||
{Checkpoint: &CheckpointDefinition{Title: "CP3"}},
|
||||
},
|
||||
}
|
||||
|
||||
count := countCheckpoints(script)
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 checkpoints, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountCheckpoints_NoCheckpoints(t *testing.T) {
|
||||
script := &NarratorScript{
|
||||
Sections: []NarratorSection{
|
||||
{Heading: "S1"},
|
||||
{Heading: "S2"},
|
||||
},
|
||||
}
|
||||
|
||||
count := countCheckpoints(script)
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 checkpoints, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountCheckpoints_EmptySections(t *testing.T) {
|
||||
script := &NarratorScript{}
|
||||
count := countCheckpoints(script)
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 checkpoints, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NarratorScript JSON Serialization Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNarratorScript_JSONRoundTrip(t *testing.T) {
|
||||
original := NarratorScript{
|
||||
Title: "Test",
|
||||
Intro: "Hello",
|
||||
Sections: []NarratorSection{
|
||||
{
|
||||
Heading: "H1",
|
||||
NarratorText: "NT1",
|
||||
BulletPoints: []string{"BP1"},
|
||||
Transition: "T1",
|
||||
Checkpoint: &CheckpointDefinition{
|
||||
Title: "CP1",
|
||||
Questions: []CheckpointQuestion{
|
||||
{
|
||||
Question: "Q?",
|
||||
Options: []string{"A", "B", "C", "D"},
|
||||
CorrectIndex: 2,
|
||||
Explanation: "C is correct",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Outro: "Bye",
|
||||
TotalDurationEstimate: 600,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded NarratorScript
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Title != original.Title {
|
||||
t.Errorf("Title mismatch: %s != %s", decoded.Title, original.Title)
|
||||
}
|
||||
if len(decoded.Sections) != 1 {
|
||||
t.Fatalf("Expected 1 section, got %d", len(decoded.Sections))
|
||||
}
|
||||
if decoded.Sections[0].Checkpoint == nil {
|
||||
t.Fatal("Checkpoint should not be nil after round-trip")
|
||||
}
|
||||
if decoded.Sections[0].Checkpoint.Questions[0].CorrectIndex != 2 {
|
||||
t.Errorf("CorrectIndex mismatch: got %d", decoded.Sections[0].Checkpoint.Questions[0].CorrectIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// InteractiveVideoManifest Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestInteractiveVideoManifest_JSON(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
StreamURL: "https://example.com/video.mp4",
|
||||
Checkpoints: []CheckpointManifestEntry{
|
||||
{
|
||||
Index: 0,
|
||||
Title: "CP1",
|
||||
TimestampSeconds: 180.5,
|
||||
Questions: []CheckpointQuestion{
|
||||
{
|
||||
Question: "Q?",
|
||||
Options: []string{"A", "B", "C", "D"},
|
||||
CorrectIndex: 1,
|
||||
Explanation: "B",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded InteractiveVideoManifest
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Checkpoints) != 1 {
|
||||
t.Fatalf("Expected 1 checkpoint, got %d", len(decoded.Checkpoints))
|
||||
}
|
||||
if decoded.Checkpoints[0].TimestampSeconds != 180.5 {
|
||||
t.Errorf("Timestamp mismatch: got %f", decoded.Checkpoints[0].TimestampSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SubmitCheckpointQuizRequest/Response Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestSubmitCheckpointQuizResponse_JSON(t *testing.T) {
|
||||
resp := SubmitCheckpointQuizResponse{
|
||||
Passed: true,
|
||||
Score: 80.0,
|
||||
Feedback: []CheckpointQuizFeedback{
|
||||
{Question: "Q1?", Correct: true, Explanation: "Correct!"},
|
||||
{Question: "Q2?", Correct: false, Explanation: "Wrong answer."},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded SubmitCheckpointQuizResponse
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if !decoded.Passed {
|
||||
t.Error("Expected passed=true")
|
||||
}
|
||||
if decoded.Score != 80.0 {
|
||||
t.Errorf("Expected score 80.0, got %f", decoded.Score)
|
||||
}
|
||||
if len(decoded.Feedback) != 2 {
|
||||
t.Fatalf("Expected 2 feedback items, got %d", len(decoded.Feedback))
|
||||
}
|
||||
if decoded.Feedback[1].Correct {
|
||||
t.Error("Second feedback should be incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// narratorSystemPrompt Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNarratorSystemPrompt_ContainsKeyPhrases(t *testing.T) {
|
||||
if !containsSubstring(narratorSystemPrompt, "AI Teacher") {
|
||||
t.Error("System prompt should mention AI Teacher")
|
||||
}
|
||||
if !containsSubstring(narratorSystemPrompt, "Checkpoint") {
|
||||
t.Error("System prompt should mention Checkpoint")
|
||||
}
|
||||
if !containsSubstring(narratorSystemPrompt, "JSON") {
|
||||
t.Error("System prompt should mention JSON format")
|
||||
}
|
||||
if !containsSubstring(narratorSystemPrompt, "correct_index") {
|
||||
t.Error("System prompt should mention correct_index")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Checkpoint Grading Logic Tests (User Journey: Learner scores quiz)
|
||||
// =============================================================================
|
||||
|
||||
func TestCheckpointGrading_AllCorrect_ScoreIs100(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{0, 1, 2}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if score != 100.0 {
|
||||
t.Errorf("Expected score 100, got %f", score)
|
||||
}
|
||||
if !passed {
|
||||
t.Error("Expected passed=true with 100% score")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_NoneCorrect_ScoreIs0(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{3, 3, 3}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if score != 0.0 {
|
||||
t.Errorf("Expected score 0, got %f", score)
|
||||
}
|
||||
if passed {
|
||||
t.Error("Expected passed=false with 0% score")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_ExactlyAt70Percent_Passes(t *testing.T) {
|
||||
// 7 out of 10 correct = 70% — exactly at threshold
|
||||
questions := make([]CheckpointQuestion, 10)
|
||||
answers := make([]int, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
questions[i] = CheckpointQuestion{
|
||||
Question: "Q?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0,
|
||||
}
|
||||
if i < 7 {
|
||||
answers[i] = 0 // correct
|
||||
} else {
|
||||
answers[i] = 1 // wrong
|
||||
}
|
||||
}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if score != 70.0 {
|
||||
t.Errorf("Expected score 70, got %f", score)
|
||||
}
|
||||
if !passed {
|
||||
t.Error("Expected passed=true at exactly 70%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_JustBelow70Percent_Fails(t *testing.T) {
|
||||
// 2 out of 3 correct = 66.67% — below threshold
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{0, 1, 3} // 2 correct, 1 wrong
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if passed {
|
||||
t.Errorf("Expected passed=false at %.2f%%", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_FewerAnswersThanQuestions_MarksUnansweredWrong(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{0} // Only 1 answer for 3 questions
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
|
||||
if correctCount != 1 {
|
||||
t.Errorf("Expected 1 correct, got %d", correctCount)
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
if score > 34 {
|
||||
t.Errorf("Expected score ~33.3%%, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_EmptyAnswers_AllWrong(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B"}, CorrectIndex: 1},
|
||||
}
|
||||
answers := []int{}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
|
||||
if correctCount != 0 {
|
||||
t.Errorf("Expected 0 correct with empty answers, got %d", correctCount)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Feedback Generation Tests (User Journey: Learner sees feedback)
|
||||
// =============================================================================
|
||||
|
||||
func TestCheckpointFeedback_CorrectAnswerGetsCorrectFlag(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Was ist DSGVO?", Options: []string{"EU-Verordnung", "Bundesgesetz"}, CorrectIndex: 0, Explanation: "EU-Verordnung"},
|
||||
{Question: "Wer ist DSB?", Options: []string{"IT-Leiter", "Datenschutzbeauftragter"}, CorrectIndex: 1, Explanation: "DSB Rolle"},
|
||||
}
|
||||
answers := []int{0, 0} // First correct, second wrong
|
||||
|
||||
feedback := make([]CheckpointQuizFeedback, len(questions))
|
||||
for i, q := range questions {
|
||||
isCorrect := false
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
isCorrect = true
|
||||
}
|
||||
feedback[i] = CheckpointQuizFeedback{
|
||||
Question: q.Question,
|
||||
Correct: isCorrect,
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
}
|
||||
|
||||
if !feedback[0].Correct {
|
||||
t.Error("First answer should be marked correct")
|
||||
}
|
||||
if feedback[1].Correct {
|
||||
t.Error("Second answer should be marked incorrect")
|
||||
}
|
||||
if feedback[0].Question != "Was ist DSGVO?" {
|
||||
t.Errorf("Unexpected question text: %s", feedback[0].Question)
|
||||
}
|
||||
if feedback[1].Explanation != "DSB Rolle" {
|
||||
t.Errorf("Explanation should be preserved: got %s", feedback[1].Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NarratorScript Pipeline Tests (User Journey: Admin generates video)
|
||||
// =============================================================================
|
||||
|
||||
func TestNarratorScript_SectionCounting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sectionCount int
|
||||
checkpointCount int
|
||||
}{
|
||||
{"3 sections, all with checkpoints", 3, 3},
|
||||
{"4 sections, 2 with checkpoints", 4, 2},
|
||||
{"1 section, no checkpoint", 1, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sections := make([]NarratorSection, tt.sectionCount)
|
||||
cpAdded := 0
|
||||
for i := 0; i < tt.sectionCount; i++ {
|
||||
sections[i] = NarratorSection{
|
||||
Heading: "Section",
|
||||
NarratorText: "Text",
|
||||
BulletPoints: []string{},
|
||||
Transition: "Next",
|
||||
}
|
||||
if cpAdded < tt.checkpointCount {
|
||||
sections[i].Checkpoint = &CheckpointDefinition{
|
||||
Title: "CP",
|
||||
Questions: []CheckpointQuestion{{Question: "Q?", Options: []string{"A", "B"}, CorrectIndex: 0}},
|
||||
}
|
||||
cpAdded++
|
||||
}
|
||||
}
|
||||
|
||||
script := &NarratorScript{
|
||||
Title: "Test",
|
||||
Intro: "Hi",
|
||||
Sections: sections,
|
||||
Outro: "Bye",
|
||||
}
|
||||
|
||||
if len(script.Sections) != tt.sectionCount {
|
||||
t.Errorf("Expected %d sections, got %d", tt.sectionCount, len(script.Sections))
|
||||
}
|
||||
if countCheckpoints(script) != tt.checkpointCount {
|
||||
t.Errorf("Expected %d checkpoints, got %d", tt.checkpointCount, countCheckpoints(script))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarratorScript_SectionAudioConversion(t *testing.T) {
|
||||
// Verify NarratorSection can be converted to SectionAudio for TTS
|
||||
sections := []NarratorSection{
|
||||
{Heading: "Einleitung", NarratorText: "Willkommen zur Schulung."},
|
||||
{Heading: "Hauptteil", NarratorText: "Hier lernen Sie die Grundlagen."},
|
||||
}
|
||||
|
||||
audioSections := make([]SectionAudio, len(sections))
|
||||
for i, s := range sections {
|
||||
audioSections[i] = SectionAudio{
|
||||
Text: s.NarratorText,
|
||||
Heading: s.Heading,
|
||||
}
|
||||
}
|
||||
|
||||
if len(audioSections) != 2 {
|
||||
t.Fatalf("Expected 2 audio sections, got %d", len(audioSections))
|
||||
}
|
||||
if audioSections[0].Heading != "Einleitung" {
|
||||
t.Errorf("Expected heading 'Einleitung', got '%s'", audioSections[0].Heading)
|
||||
}
|
||||
if audioSections[1].Text != "Hier lernen Sie die Grundlagen." {
|
||||
t.Errorf("Unexpected text: '%s'", audioSections[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// InteractiveVideoManifest Progress Tests (User Journey: Learner resumes)
|
||||
// =============================================================================
|
||||
|
||||
func TestManifest_IdentifiesNextUnpassedCheckpoint(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
StreamURL: "https://example.com/video.mp4",
|
||||
Checkpoints: []CheckpointManifestEntry{
|
||||
{Index: 0, Title: "CP1", TimestampSeconds: 180, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, Title: "CP2", TimestampSeconds: 360, Progress: &CheckpointProgress{Passed: false}},
|
||||
{Index: 2, Title: "CP3", TimestampSeconds: 540, Progress: nil},
|
||||
},
|
||||
}
|
||||
|
||||
var nextUnpassed *CheckpointManifestEntry
|
||||
for i := range manifest.Checkpoints {
|
||||
cp := &manifest.Checkpoints[i]
|
||||
if cp.Progress == nil || !cp.Progress.Passed {
|
||||
nextUnpassed = cp
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if nextUnpassed == nil {
|
||||
t.Fatal("Expected to find an unpassed checkpoint")
|
||||
}
|
||||
if nextUnpassed.Index != 1 {
|
||||
t.Errorf("Expected next unpassed at index 1, got %d", nextUnpassed.Index)
|
||||
}
|
||||
if nextUnpassed.Title != "CP2" {
|
||||
t.Errorf("Expected CP2, got %s", nextUnpassed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_AllCheckpointsPassed(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
Checkpoints: []CheckpointManifestEntry{
|
||||
{Index: 0, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, Progress: &CheckpointProgress{Passed: true}},
|
||||
},
|
||||
}
|
||||
|
||||
allPassed := true
|
||||
for _, cp := range manifest.Checkpoints {
|
||||
if cp.Progress == nil || !cp.Progress.Passed {
|
||||
allPassed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allPassed {
|
||||
t.Error("Expected all checkpoints to be passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_NoCheckpoints_AllPassedIsTrue(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
Checkpoints: []CheckpointManifestEntry{},
|
||||
}
|
||||
|
||||
allPassed := true
|
||||
for _, cp := range manifest.Checkpoints {
|
||||
if cp.Progress == nil || !cp.Progress.Passed {
|
||||
allPassed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allPassed {
|
||||
t.Error("Empty checkpoint list should be considered all-passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_SeekProtection_BlocksSkippingPastUnpassed(t *testing.T) {
|
||||
// Simulates seek protection logic from InteractiveVideoPlayer
|
||||
checkpoints := []CheckpointManifestEntry{
|
||||
{Index: 0, TimestampSeconds: 180, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, TimestampSeconds: 360, Progress: nil}, // Not yet attempted
|
||||
{Index: 2, TimestampSeconds: 540, Progress: nil},
|
||||
}
|
||||
|
||||
seekTarget := 500.0 // User tries to seek to 500s
|
||||
|
||||
// Find first unpassed checkpoint
|
||||
var firstUnpassed *CheckpointManifestEntry
|
||||
for i := range checkpoints {
|
||||
if checkpoints[i].Progress == nil || !checkpoints[i].Progress.Passed {
|
||||
firstUnpassed = &checkpoints[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
blocked := false
|
||||
if firstUnpassed != nil && seekTarget > firstUnpassed.TimestampSeconds {
|
||||
blocked = true
|
||||
}
|
||||
|
||||
if !blocked {
|
||||
t.Error("Seek past unpassed checkpoint should be blocked")
|
||||
}
|
||||
if firstUnpassed.TimestampSeconds != 360 {
|
||||
t.Errorf("Expected block at 360s, got %f", firstUnpassed.TimestampSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_SeekProtection_AllowsSeekBeforeFirstUnpassed(t *testing.T) {
|
||||
checkpoints := []CheckpointManifestEntry{
|
||||
{Index: 0, TimestampSeconds: 180, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, TimestampSeconds: 360, Progress: nil},
|
||||
}
|
||||
|
||||
seekTarget := 200.0 // User seeks to 200s — before unpassed checkpoint at 360s
|
||||
|
||||
var firstUnpassed *CheckpointManifestEntry
|
||||
for i := range checkpoints {
|
||||
if checkpoints[i].Progress == nil || !checkpoints[i].Progress.Passed {
|
||||
firstUnpassed = &checkpoints[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
blocked := false
|
||||
if firstUnpassed != nil && seekTarget > firstUnpassed.TimestampSeconds {
|
||||
blocked = true
|
||||
}
|
||||
|
||||
if blocked {
|
||||
t.Error("Seek before unpassed checkpoint should be allowed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user