Files
Benjamin Admin 4f6bc8f6f6
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
feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
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>
2026-03-16 21:41:48 +01:00

802 lines
23 KiB
Go

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")
}
}