Files
breakpilot-compliance/ai-compliance-sdk/internal/training/content_generator_test.go
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

553 lines
15 KiB
Go

package training
import (
"testing"
"github.com/google/uuid"
)
// =============================================================================
// buildContentPrompt Tests
// =============================================================================
func TestBuildContentPrompt_ContainsModuleCode(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-TRAIN-001",
Title: "DSGVO Grundlagen",
Description: "Basis-Schulung",
RegulationArea: RegulationDSGVO,
DurationMinutes: 30,
}
prompt := buildContentPrompt(module, "de")
if !containsSubstring(prompt, "CP-TRAIN-001") {
t.Error("Prompt should contain module code")
}
}
func TestBuildContentPrompt_ContainsTitle(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "DSGVO Grundlagen",
RegulationArea: RegulationDSGVO,
DurationMinutes: 30,
}
prompt := buildContentPrompt(module, "de")
if !containsSubstring(prompt, "DSGVO Grundlagen") {
t.Error("Prompt should contain module title")
}
}
func TestBuildContentPrompt_ContainsRegulationLabel(t *testing.T) {
tests := []struct {
name string
area RegulationArea
expected string
}{
{"DSGVO", RegulationDSGVO, "Datenschutz-Grundverordnung"},
{"NIS2", RegulationNIS2, "NIS-2-Richtlinie"},
{"ISO27001", RegulationISO27001, "ISO 27001"},
{"AIAct", RegulationAIAct, "AI Act"},
{"GeschGehG", RegulationGeschGehG, "Geschaeftsgeheimnisgesetz"},
{"HinSchG", RegulationHinSchG, "Hinweisgeberschutzgesetz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test Module",
RegulationArea: tt.area,
DurationMinutes: 30,
}
prompt := buildContentPrompt(module, "de")
if !containsSubstring(prompt, tt.expected) {
t.Errorf("Prompt should contain regulation label '%s' for area '%s'", tt.expected, tt.area)
}
})
}
}
func TestBuildContentPrompt_ContainsDuration(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test",
RegulationArea: RegulationDSGVO,
DurationMinutes: 45,
}
prompt := buildContentPrompt(module, "de")
if !containsSubstring(prompt, "45 Minuten") {
t.Error("Prompt should contain duration in minutes")
}
}
func TestBuildContentPrompt_UnknownRegulationArea(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test",
RegulationArea: RegulationArea("custom_regulation"),
DurationMinutes: 30,
}
prompt := buildContentPrompt(module, "de")
if !containsSubstring(prompt, "custom_regulation") {
t.Error("Unknown regulation area should fall back to raw string")
}
}
// =============================================================================
// buildQuizPrompt Tests
// =============================================================================
func TestBuildQuizPrompt_ContainsQuestionCount(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test Module",
RegulationArea: RegulationDSGVO,
}
prompt := buildQuizPrompt(module, "", 10)
if !containsSubstring(prompt, "10") {
t.Error("Quiz prompt should contain question count")
}
}
func TestBuildQuizPrompt_ContainsContentContext(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test",
RegulationArea: RegulationDSGVO,
}
prompt := buildQuizPrompt(module, "This is the module content about DSGVO.", 5)
if !containsSubstring(prompt, "This is the module content about DSGVO.") {
t.Error("Quiz prompt should include content context")
}
}
func TestBuildQuizPrompt_TruncatesLongContent(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test",
RegulationArea: RegulationDSGVO,
}
// Create content longer than 3000 chars
longContent := ""
for i := 0; i < 400; i++ {
longContent += "ABCDEFGHIJ" // 10 chars * 400 = 4000 chars
}
prompt := buildQuizPrompt(module, longContent, 5)
if containsSubstring(prompt, longContent) {
t.Error("Quiz prompt should truncate content longer than 3000 chars")
}
if !containsSubstring(prompt, "...") {
t.Error("Truncated content should end with '...'")
}
}
func TestBuildQuizPrompt_EmptyContent(t *testing.T) {
module := TrainingModule{
ModuleCode: "CP-001",
Title: "Test",
RegulationArea: RegulationDSGVO,
}
prompt := buildQuizPrompt(module, "", 5)
if containsSubstring(prompt, "Schulungsinhalt als Kontext") {
t.Error("Empty content should not add context section")
}
}
// =============================================================================
// parseQuizResponse Tests
// =============================================================================
func TestParseQuizResponse_ValidJSON(t *testing.T) {
moduleID := uuid.New()
response := `[
{
"question": "Was ist die DSGVO?",
"options": ["EU-Verordnung", "Bundesgesetz", "Landesgesetz", "Internationale Konvention"],
"correct_index": 0,
"explanation": "Die DSGVO ist eine EU-Verordnung.",
"difficulty": "easy"
}
]`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 1 {
t.Fatalf("Expected 1 question, got %d", len(questions))
}
if questions[0].Question != "Was ist die DSGVO?" {
t.Errorf("Expected question text, got '%s'", questions[0].Question)
}
if questions[0].CorrectIndex != 0 {
t.Errorf("Expected correct_index 0, got %d", questions[0].CorrectIndex)
}
if questions[0].Difficulty != DifficultyEasy {
t.Errorf("Expected difficulty 'easy', got '%s'", questions[0].Difficulty)
}
if questions[0].ModuleID != moduleID {
t.Error("Module ID should be set on parsed question")
}
if !questions[0].IsActive {
t.Error("Parsed questions should be active by default")
}
}
func TestParseQuizResponse_InvalidJSON(t *testing.T) {
moduleID := uuid.New()
_, err := parseQuizResponse("not valid json at all", moduleID)
if err == nil {
t.Error("Expected error for invalid JSON")
}
}
func TestParseQuizResponse_JSONWithSurroundingText(t *testing.T) {
moduleID := uuid.New()
response := `Here are the questions:
[
{
"question": "Test?",
"options": ["A", "B", "C", "D"],
"correct_index": 1,
"explanation": "B is correct.",
"difficulty": "medium"
}
]
I hope these are helpful!`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 1 {
t.Fatalf("Expected 1 question, got %d", len(questions))
}
}
func TestParseQuizResponse_SkipsMalformedOptions(t *testing.T) {
moduleID := uuid.New()
response := `[
{
"question": "Good question?",
"options": ["A", "B", "C", "D"],
"correct_index": 0,
"explanation": "A is correct.",
"difficulty": "easy"
},
{
"question": "Bad question?",
"options": ["A", "B"],
"correct_index": 0,
"explanation": "Only 2 options.",
"difficulty": "easy"
}
]`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 1 {
t.Errorf("Expected 1 valid question (malformed should be skipped), got %d", len(questions))
}
}
func TestParseQuizResponse_SkipsInvalidCorrectIndex(t *testing.T) {
moduleID := uuid.New()
response := `[
{
"question": "Bad index?",
"options": ["A", "B", "C", "D"],
"correct_index": 5,
"explanation": "Index out of range.",
"difficulty": "medium"
}
]`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 0 {
t.Errorf("Expected 0 questions (invalid index should be skipped), got %d", len(questions))
}
}
func TestParseQuizResponse_NegativeCorrectIndex(t *testing.T) {
moduleID := uuid.New()
response := `[
{
"question": "Negative index?",
"options": ["A", "B", "C", "D"],
"correct_index": -1,
"explanation": "Negative index.",
"difficulty": "easy"
}
]`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 0 {
t.Errorf("Expected 0 questions (negative index should be skipped), got %d", len(questions))
}
}
func TestParseQuizResponse_DefaultsDifficultyToMedium(t *testing.T) {
moduleID := uuid.New()
response := `[
{
"question": "Test?",
"options": ["A", "B", "C", "D"],
"correct_index": 0,
"explanation": "A is correct.",
"difficulty": "unknown_difficulty"
}
]`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 1 {
t.Fatalf("Expected 1 question, got %d", len(questions))
}
if questions[0].Difficulty != DifficultyMedium {
t.Errorf("Expected difficulty to default to 'medium', got '%s'", questions[0].Difficulty)
}
}
func TestParseQuizResponse_MultipleQuestions(t *testing.T) {
moduleID := uuid.New()
response := `[
{"question":"Q1?","options":["A","B","C","D"],"correct_index":0,"explanation":"","difficulty":"easy"},
{"question":"Q2?","options":["A","B","C","D"],"correct_index":1,"explanation":"","difficulty":"medium"},
{"question":"Q3?","options":["A","B","C","D"],"correct_index":2,"explanation":"","difficulty":"hard"}
]`
questions, err := parseQuizResponse(response, moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 3 {
t.Errorf("Expected 3 questions, got %d", len(questions))
}
}
func TestParseQuizResponse_EmptyArray(t *testing.T) {
moduleID := uuid.New()
questions, err := parseQuizResponse("[]", moduleID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(questions) != 0 {
t.Errorf("Expected 0 questions, got %d", len(questions))
}
}
// =============================================================================
// truncateText Tests
// =============================================================================
func TestTruncateText_ShortText(t *testing.T) {
result := truncateText("hello", 100)
if result != "hello" {
t.Errorf("Short text should not be truncated, got '%s'", result)
}
}
func TestTruncateText_ExactLength(t *testing.T) {
result := truncateText("12345", 5)
if result != "12345" {
t.Errorf("Text at exact max length should not be truncated, got '%s'", result)
}
}
func TestTruncateText_LongText(t *testing.T) {
result := truncateText("1234567890", 5)
if result != "12345..." {
t.Errorf("Expected '12345...', got '%s'", result)
}
}
func TestTruncateText_EmptyString(t *testing.T) {
result := truncateText("", 10)
if result != "" {
t.Errorf("Empty string should remain empty, got '%s'", result)
}
}
// =============================================================================
// System Prompt Tests
// =============================================================================
func TestGetContentSystemPrompt_German(t *testing.T) {
prompt := getContentSystemPrompt("de")
if !containsSubstring(prompt, "Compliance-Schulungsinhalte") {
t.Error("German system prompt should mention Compliance-Schulungsinhalte")
}
if !containsSubstring(prompt, "Markdown") {
t.Error("System prompt should mention Markdown format")
}
}
func TestGetContentSystemPrompt_English(t *testing.T) {
prompt := getContentSystemPrompt("en")
if !containsSubstring(prompt, "compliance training content") {
t.Error("English system prompt should mention compliance training content")
}
}
func TestGetQuizSystemPrompt_ContainsJSONFormat(t *testing.T) {
prompt := getQuizSystemPrompt()
if !containsSubstring(prompt, "JSON") {
t.Error("Quiz system prompt should mention JSON format")
}
if !containsSubstring(prompt, "correct_index") {
t.Error("Quiz system prompt should show correct_index field")
}
}
// =============================================================================
// buildBlockContentPrompt Tests
// =============================================================================
func TestBuildBlockContentPrompt_ContainsModuleInfo(t *testing.T) {
module := TrainingModule{
ModuleCode: "BLK-AUTH-001",
Title: "Authentication Controls",
DurationMinutes: 45,
}
controls := []CanonicalControlSummary{
{
ControlID: "AUTH-001",
Title: "Multi-Factor Authentication",
Objective: "Ensure MFA is enabled",
Requirements: []string{"Enable MFA for all users"},
},
}
prompt := buildBlockContentPrompt(module, controls, "de")
if !containsSubstring(prompt, "BLK-AUTH-001") {
t.Error("Block prompt should contain module code")
}
if !containsSubstring(prompt, "Authentication Controls") {
t.Error("Block prompt should contain module title")
}
if !containsSubstring(prompt, "45 Minuten") {
t.Error("Block prompt should contain duration")
}
}
func TestBuildBlockContentPrompt_ContainsControlDetails(t *testing.T) {
module := TrainingModule{
ModuleCode: "BLK-001",
Title: "Test",
DurationMinutes: 30,
}
controls := []CanonicalControlSummary{
{
ControlID: "CTRL-001",
Title: "Test Control",
Objective: "Test objective",
Requirements: []string{"Req 1", "Req 2"},
},
}
prompt := buildBlockContentPrompt(module, controls, "de")
if !containsSubstring(prompt, "CTRL-001") {
t.Error("Prompt should contain control ID")
}
if !containsSubstring(prompt, "Test Control") {
t.Error("Prompt should contain control title")
}
if !containsSubstring(prompt, "Test objective") {
t.Error("Prompt should contain control objective")
}
if !containsSubstring(prompt, "Req 1") {
t.Error("Prompt should contain control requirements")
}
}
func TestBuildBlockContentPrompt_EnglishVersion(t *testing.T) {
module := TrainingModule{
ModuleCode: "BLK-001",
Title: "Test",
DurationMinutes: 30,
}
controls := []CanonicalControlSummary{}
prompt := buildBlockContentPrompt(module, controls, "en")
if !containsSubstring(prompt, "Create training material") {
t.Error("English prompt should use English text")
}
}
func TestBuildBlockContentPrompt_MultipleControls(t *testing.T) {
module := TrainingModule{
ModuleCode: "BLK-001",
Title: "Test",
DurationMinutes: 30,
}
controls := []CanonicalControlSummary{
{ControlID: "CTRL-001", Title: "First Control", Objective: "Obj 1"},
{ControlID: "CTRL-002", Title: "Second Control", Objective: "Obj 2"},
{ControlID: "CTRL-003", Title: "Third Control", Objective: "Obj 3"},
}
prompt := buildBlockContentPrompt(module, controls, "de")
if !containsSubstring(prompt, "3 Sicherheits-Controls") {
t.Error("Prompt should mention the count of controls")
}
if !containsSubstring(prompt, "Control 1") {
t.Error("Prompt should number controls")
}
if !containsSubstring(prompt, "Control 3") {
t.Error("Prompt should include all controls")
}
}
// =============================================================================
// Helpers
// =============================================================================
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
if len(substr) == 0 {
return true
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}