refactor: comprehensive code quality improvements

- Add logger to all exporters for consistent error handling
- Add Course.Validate() method with ValidationError type
- Create shared test fixtures in internal/testdata
- Refactor config passing: NewArticulateParser now takes *config.Config
- Add NewLoggerFromConfig() factory for logger creation
- Add context cancellation with signal handling (SIGINT/SIGTERM)
- Add godoc examples for loggers and exporters
- Add HTML exporter error path tests
- Add fuzz tests for parser and HTMLCleaner
- Fix markdown output: trim whitespace, collapse blank lines
- Remove test output files from repo
This commit is contained in:
2026-09-21 09:45:05 +02:00
parent 8ec9dd4f20
commit adb60ca7e5
29 changed files with 1940 additions and 415 deletions
+467
View File
@@ -0,0 +1,467 @@
// Package testdata provides shared test fixtures for Articulate Rise course testing.
// It contains builders for creating Course, Lesson, and Item structures with various
// configurations to support unit tests, integration tests, and benchmarks.
package testdata
import (
"fmt"
"github.com/kjanat/articulate-parser/internal/models"
)
// SampleAnswers provides a reusable set of quiz answers for testing.
var SampleAnswers = []models.Answer{
{ID: "ans-1", Title: "Option A", Correct: false},
{ID: "ans-2", Title: "Option B", Correct: true},
{ID: "ans-3", Title: "Option C", Correct: false},
{ID: "ans-4", Title: "Option D", Correct: false},
}
// SampleSubItems provides a reusable set of sub-items for testing.
var SampleSubItems = []models.SubItem{
{
ID: "sub-1",
Heading: "Sample Heading",
Paragraph: "<p>Sample paragraph with <strong>formatting</strong>.</p>",
},
{
ID: "sub-2",
Paragraph: "<p>Another paragraph with <em>emphasis</em>.</p>",
},
}
// MinimalCourse returns a course with just ShareID and Title set.
// Useful for testing edge cases with minimal data.
func MinimalCourse() *models.Course {
return &models.Course{
ShareID: "minimal-share-id",
Course: models.CourseInfo{
Title: "Minimal Course",
},
}
}
// BasicCourse returns a course with 1 section, 1 lesson, and 1 text item.
// Suitable for basic functionality tests.
func BasicCourse() *models.Course {
return &models.Course{
ShareID: "basic-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "basic-course-id",
Title: "Basic Test Course",
Description: "<p>A basic course for testing.</p>",
NavigationMode: "menu",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Introduction Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "First Lesson",
Type: "lesson",
Description: "<p>Lesson introduction text.</p>",
Items: []models.Item{
TextItem("Welcome", "<p>Welcome to the course!</p>"),
},
},
},
},
}
}
// CompleteCourse returns a course with all item types represented.
// Useful for comprehensive export and parsing tests.
func CompleteCourse() *models.Course {
return &models.Course{
ShareID: "complete-share-id",
Author: "Complete Test Author",
Course: models.CourseInfo{
ID: "complete-course-id",
Title: "Complete Test Course",
Description: "<p>A comprehensive course with <strong>all</strong> item types.</p>",
Color: "#3366CC",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Title: "Exported Complete Course",
Format: "scorm",
},
CoverImage: &models.Media{
Image: &models.ImageMedia{
Key: "cover-img-key",
Type: "jpg",
Width: 1920,
Height: 1080,
OriginalURL: "https://example.com/cover.jpg",
},
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Overview",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction",
Type: "lesson",
Description: "<p>Introduction to the course content.</p>",
Icon: "book",
Items: []models.Item{
TextItem("Welcome", "<p>Welcome to this comprehensive course!</p>"),
TextItem("Objectives", "<p>In this course you will learn:</p>"),
ListItem("First objective", "Second objective", "Third objective"),
DividerItem(),
MediaItem("video", "https://example.com/intro.mp4"),
MediaItem("image", "https://example.com/diagram.png"),
},
},
{
ID: "lesson-2",
Title: "Core Concepts",
Type: "lesson",
Description: "<p>Core concepts and fundamentals.</p>",
Items: []models.Item{
TextItem("Key Concepts", "<p>Let's explore the key concepts.</p>"),
KnowledgeCheckItem("What is the primary purpose of this course?", []models.Answer{
{ID: "kc-1-a", Title: "Learning basics", Correct: false},
{ID: "kc-1-b", Title: "Comprehensive understanding", Correct: true},
{ID: "kc-1-c", Title: "Quick overview", Correct: false},
}),
InteractiveItem("Interactive Exercise"),
},
},
{
ID: "lesson-3",
Title: "Advanced Topics",
Type: "lesson",
Description: "<p>Deep dive into advanced topics.</p>",
Items: []models.Item{
TextItem("Advanced Section", "<p>This section covers advanced material.</p>"),
FlipCardItem("Front content", "Back content"),
QuoteItem("Learning is not attained by chance.", "Abigail Adams"),
},
},
{
ID: "section-2",
Title: "Summary",
Type: "section",
},
{
ID: "lesson-4",
Title: "Conclusion",
Type: "lesson",
Description: "<p>Course wrap-up and next steps.</p>",
Items: []models.Item{
TextItem("Summary", "<p>Thank you for completing this course!</p>"),
ListItem("Review materials regularly", "Practice learned skills", "Share knowledge"),
},
},
},
},
LabelSet: models.LabelSet{
ID: "default-labels",
Name: "Default",
Labels: map[string]string{
"next": "Next",
"previous": "Previous",
"complete": "Mark Complete",
},
},
}
}
// LargeCourse returns a parameterized course for benchmarking with the specified number of lessons.
// Each lesson contains multiple item types for realistic performance testing.
func LargeCourse(lessons int) *models.Course {
courseLessons := make([]models.Lesson, 0, lessons+lessons/5) // +sections
for i := range lessons {
// Add section header every 5 lessons
if i%5 == 0 {
courseLessons = append(courseLessons, models.Lesson{
ID: fmt.Sprintf("section-%d", i/5+1),
Title: fmt.Sprintf("Section %d", i/5+1),
Type: "section",
})
}
lesson := models.Lesson{
ID: fmt.Sprintf("lesson-%d", i+1),
Title: fmt.Sprintf("Lesson %d", i+1),
Type: "lesson",
Description: fmt.Sprintf("<p>Description for lesson %d with <em>formatting</em>.</p>", i+1),
Items: []models.Item{
TextItem(
fmt.Sprintf("Heading %d", i+1),
fmt.Sprintf("<p>Content for lesson %d with <strong>bold</strong> and <em>italic</em> text.</p>", i+1),
),
ListItem("Point 1", "Point 2", "Point 3"),
KnowledgeCheckItem(
fmt.Sprintf("Quiz Question %d", i+1),
[]models.Answer{
{ID: fmt.Sprintf("q%d-a", i), Title: "Answer A", Correct: false},
{ID: fmt.Sprintf("q%d-b", i), Title: "Answer B", Correct: true},
{ID: fmt.Sprintf("q%d-c", i), Title: "Answer C", Correct: false},
},
),
},
}
courseLessons = append(courseLessons, lesson)
}
return &models.Course{
ShareID: "large-benchmark-id",
Author: "Benchmark Author",
Course: models.CourseInfo{
ID: "large-benchmark-course",
Title: fmt.Sprintf("Large Benchmark Course (%d lessons)", lessons),
Description: "<p>Large course for performance testing.</p>",
NavigationMode: "menu",
Lessons: courseLessons,
},
}
}
// TextItem creates a text item with the specified heading and paragraph content.
func TextItem(heading, paragraph string) models.Item {
return models.Item{
Type: "text",
Family: "text",
Items: []models.SubItem{
{
ID: "text-sub-1",
Heading: heading,
Paragraph: paragraph,
},
},
}
}
// ListItem creates a list item with the specified items as list entries.
func ListItem(items ...string) models.Item {
subItems := make([]models.SubItem, len(items))
for i, item := range items {
subItems[i] = models.SubItem{
ID: fmt.Sprintf("list-sub-%d", i+1),
Paragraph: fmt.Sprintf("<p>%s</p>", item),
}
}
return models.Item{
Type: "list",
Family: "list",
Items: subItems,
}
}
// KnowledgeCheckItem creates a knowledge check item with the specified question and answers.
func KnowledgeCheckItem(question string, answers []models.Answer) models.Item {
return models.Item{
Type: "knowledgeCheck",
Family: "knowledge",
Items: []models.SubItem{
{
ID: "kc-sub-1",
Title: fmt.Sprintf("<p>%s</p>", question),
Answers: answers,
Feedback: "<p>Thank you for your response.</p>",
},
},
}
}
// MediaItem creates a media item (video or image) with the specified type and URL.
// The mediaType should be "video" or "image".
func MediaItem(mediaType, url string) models.Item {
var media *models.Media
switch mediaType {
case "video":
media = &models.Media{
Video: &models.VideoMedia{
Key: "video-key",
URL: url,
Type: "mp4",
Duration: 120,
OriginalURL: url,
},
}
case "image":
media = &models.Media{
Image: &models.ImageMedia{
Key: "image-key",
Type: "png",
Width: 800,
Height: 600,
OriginalURL: url,
},
}
default:
media = &models.Media{}
}
return models.Item{
Type: "multimedia",
Family: "media",
Items: []models.SubItem{
{
ID: "media-sub-1",
Title: fmt.Sprintf("<p>%s content</p>", mediaType),
Caption: fmt.Sprintf("<p>Caption for %s</p>", mediaType),
Media: media,
},
},
}
}
// InteractiveItem creates an interactive content item with the specified title.
func InteractiveItem(title string) models.Item {
return models.Item{
Type: "interactive",
Family: "interactive",
Items: []models.SubItem{
{
ID: "interactive-sub-1",
Title: fmt.Sprintf("<p>%s</p>", title),
},
},
}
}
// DividerItem creates a divider/separator item.
func DividerItem() models.Item {
return models.Item{
Type: "divider",
Family: "divider",
}
}
// FlipCardItem creates a flashcard-style item with front and back content.
func FlipCardItem(front, back string) models.Item {
return models.Item{
Type: "flashcard",
Family: "interactive",
Items: []models.SubItem{
{
ID: "flipcard-sub-1",
Front: &models.CardSide{
Description: fmt.Sprintf("<p>%s</p>", front),
},
Back: &models.CardSide{
Description: fmt.Sprintf("<p>%s</p>", back),
},
},
},
}
}
// QuoteItem creates a quote/blockquote item with text and attribution.
func QuoteItem(quote, author string) models.Item {
return models.Item{
Type: "quote",
Family: "text",
Items: []models.SubItem{
{
ID: "quote-sub-1",
Paragraph: fmt.Sprintf("<blockquote>%s</blockquote>", quote),
Title: author,
},
},
}
}
// ImageItem creates a standalone image item with caption.
func ImageItem(url, caption string) models.Item {
return models.Item{
Type: "image",
Family: "media",
Items: []models.SubItem{
{
ID: "image-sub-1",
Caption: fmt.Sprintf("<p>%s</p>", caption),
Media: &models.Media{
Image: &models.ImageMedia{
Key: "standalone-img-key",
Type: "jpg",
Width: 1200,
Height: 800,
OriginalURL: url,
},
},
},
},
}
}
// VideoItem creates a standalone video item with caption.
func VideoItem(url string, duration int, caption string) models.Item {
return models.Item{
Type: "video",
Family: "media",
Items: []models.SubItem{
{
ID: "video-sub-1",
Caption: fmt.Sprintf("<p>%s</p>", caption),
Media: &models.Media{
Video: &models.VideoMedia{
Key: "standalone-video-key",
URL: url,
Type: "mp4",
Duration: duration,
OriginalURL: url,
},
},
},
},
}
}
// MatchingItem creates a matching/pairing knowledge check item.
func MatchingItem(pairs map[string]string) models.Item {
answers := make([]models.Answer, 0, len(pairs))
i := 0
for term, match := range pairs {
answers = append(answers, models.Answer{
ID: fmt.Sprintf("match-%d", i),
Title: term,
MatchTitle: match,
Correct: true,
})
i++
}
return models.Item{
Type: "matching",
Family: "knowledge",
Items: []models.SubItem{
{
ID: "matching-sub-1",
Title: "<p>Match the following items:</p>",
Answers: answers,
Feedback: "<p>Check your matches!</p>",
},
},
}
}
// EmptyLesson returns a lesson with no items for edge case testing.
func EmptyLesson(id, title string) models.Lesson {
return models.Lesson{
ID: id,
Title: title,
Type: "lesson",
Items: []models.Item{},
}
}
// SectionLesson returns a section header (not a content lesson).
func SectionLesson(id, title string) models.Lesson {
return models.Lesson{
ID: id,
Title: title,
Type: "section",
}
}
+273
View File
@@ -0,0 +1,273 @@
package testdata
import (
"testing"
"github.com/kjanat/articulate-parser/internal/models"
)
func TestMinimalCourse(t *testing.T) {
course := MinimalCourse()
if course == nil {
t.Fatal("MinimalCourse() returned nil")
}
if course.ShareID == "" {
t.Error("MinimalCourse() should have ShareID set")
}
if course.Course.Title == "" {
t.Error("MinimalCourse() should have Title set")
}
}
func TestBasicCourse(t *testing.T) {
course := BasicCourse()
if course == nil {
t.Fatal("BasicCourse() returned nil")
}
if len(course.Course.Lessons) != 2 {
t.Errorf("BasicCourse() should have 2 lessons, got %d", len(course.Course.Lessons))
}
if course.Course.Lessons[0].Type != "section" {
t.Error("First lesson should be a section")
}
if course.Course.Lessons[1].Type != "lesson" {
t.Error("Second lesson should be a lesson")
}
if len(course.Course.Lessons[1].Items) != 1 {
t.Errorf("Lesson should have 1 item, got %d", len(course.Course.Lessons[1].Items))
}
}
func TestCompleteCourse(t *testing.T) {
course := CompleteCourse()
if course == nil {
t.Fatal("CompleteCourse() returned nil")
}
if len(course.Course.Lessons) == 0 {
t.Error("CompleteCourse() should have lessons")
}
// Verify all expected item types exist
itemTypes := make(map[string]bool)
for _, lesson := range course.Course.Lessons {
for _, item := range lesson.Items {
itemTypes[item.Type] = true
}
}
expectedTypes := []string{"text", "list", "divider", "multimedia", "knowledgeCheck", "interactive", "flashcard", "quote"}
for _, et := range expectedTypes {
if !itemTypes[et] {
t.Errorf("CompleteCourse() should have item type %q", et)
}
}
}
func TestLargeCourse(t *testing.T) {
tests := []struct {
lessons int
expected int
}{
{1, 2}, // 1 lesson + 1 section
{5, 6}, // 5 lessons + 1 section
{10, 12}, // 10 lessons + 2 sections
{50, 60}, // 50 lessons + 10 sections
}
for _, tt := range tests {
course := LargeCourse(tt.lessons)
if course == nil {
t.Fatalf("LargeCourse(%d) returned nil", tt.lessons)
}
if len(course.Course.Lessons) != tt.expected {
t.Errorf("LargeCourse(%d) should have %d lessons+sections, got %d",
tt.lessons, tt.expected, len(course.Course.Lessons))
}
}
}
func TestTextItem(t *testing.T) {
item := TextItem("Test Heading", "<p>Test paragraph</p>")
if item.Type != "text" {
t.Errorf("TextItem Type should be 'text', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("TextItem should have 1 sub-item, got %d", len(item.Items))
}
if item.Items[0].Heading != "Test Heading" {
t.Errorf("TextItem heading mismatch: got %q", item.Items[0].Heading)
}
}
func TestListItem(t *testing.T) {
item := ListItem("Item 1", "Item 2", "Item 3")
if item.Type != "list" {
t.Errorf("ListItem Type should be 'list', got %q", item.Type)
}
if len(item.Items) != 3 {
t.Errorf("ListItem should have 3 sub-items, got %d", len(item.Items))
}
}
func TestKnowledgeCheckItem(t *testing.T) {
answers := []models.Answer{
{Title: "A", Correct: false},
{Title: "B", Correct: true},
}
item := KnowledgeCheckItem("Test question?", answers)
if item.Type != "knowledgeCheck" {
t.Errorf("KnowledgeCheckItem Type should be 'knowledgeCheck', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("KnowledgeCheckItem should have 1 sub-item, got %d", len(item.Items))
}
if len(item.Items[0].Answers) != 2 {
t.Errorf("KnowledgeCheckItem should have 2 answers, got %d", len(item.Items[0].Answers))
}
}
func TestMediaItem(t *testing.T) {
tests := []struct {
name string
mediaType string
hasVideo bool
hasImage bool
}{
{"video", "video", true, false},
{"image", "image", false, true},
{"unknown", "unknown", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
item := MediaItem(tt.mediaType, "https://example.com/media")
if item.Type != "multimedia" {
t.Errorf("MediaItem Type should be 'multimedia', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("MediaItem should have 1 sub-item, got %d", len(item.Items))
}
if tt.hasVideo && item.Items[0].Media.Video == nil {
t.Error("Expected video media to be set")
}
if tt.hasImage && item.Items[0].Media.Image == nil {
t.Error("Expected image media to be set")
}
})
}
}
func TestInteractiveItem(t *testing.T) {
item := InteractiveItem("Interactive Title")
if item.Type != "interactive" {
t.Errorf("InteractiveItem Type should be 'interactive', got %q", item.Type)
}
}
func TestDividerItem(t *testing.T) {
item := DividerItem()
if item.Type != "divider" {
t.Errorf("DividerItem Type should be 'divider', got %q", item.Type)
}
}
func TestFlipCardItem(t *testing.T) {
item := FlipCardItem("Front text", "Back text")
if item.Type != "flashcard" {
t.Errorf("FlipCardItem Type should be 'flashcard', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("FlipCardItem should have 1 sub-item, got %d", len(item.Items))
}
if item.Items[0].Front == nil || item.Items[0].Back == nil {
t.Error("FlipCardItem should have Front and Back set")
}
}
func TestQuoteItem(t *testing.T) {
item := QuoteItem("Test quote", "Test author")
if item.Type != "quote" {
t.Errorf("QuoteItem Type should be 'quote', got %q", item.Type)
}
}
func TestImageItem(t *testing.T) {
item := ImageItem("https://example.com/img.jpg", "Image caption")
if item.Type != "image" {
t.Errorf("ImageItem Type should be 'image', got %q", item.Type)
}
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Image == nil {
t.Error("ImageItem should have image media set")
}
}
func TestVideoItem(t *testing.T) {
item := VideoItem("https://example.com/video.mp4", 120, "Video caption")
if item.Type != "video" {
t.Errorf("VideoItem Type should be 'video', got %q", item.Type)
}
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Video == nil {
t.Error("VideoItem should have video media set")
}
if item.Items[0].Media.Video.Duration != 120 {
t.Errorf("VideoItem duration should be 120, got %d", item.Items[0].Media.Video.Duration)
}
}
func TestMatchingItem(t *testing.T) {
pairs := map[string]string{
"Term 1": "Match 1",
"Term 2": "Match 2",
}
item := MatchingItem(pairs)
if item.Type != "matching" {
t.Errorf("MatchingItem Type should be 'matching', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("MatchingItem should have 1 sub-item, got %d", len(item.Items))
}
if len(item.Items[0].Answers) != 2 {
t.Errorf("MatchingItem should have 2 answers, got %d", len(item.Items[0].Answers))
}
}
func TestEmptyLesson(t *testing.T) {
lesson := EmptyLesson("test-id", "Test Title")
if lesson.ID != "test-id" {
t.Errorf("EmptyLesson ID mismatch: got %q", lesson.ID)
}
if lesson.Type != "lesson" {
t.Errorf("EmptyLesson Type should be 'lesson', got %q", lesson.Type)
}
if len(lesson.Items) != 0 {
t.Errorf("EmptyLesson should have no items, got %d", len(lesson.Items))
}
}
func TestSectionLesson(t *testing.T) {
lesson := SectionLesson("section-id", "Section Title")
if lesson.Type != "section" {
t.Errorf("SectionLesson Type should be 'section', got %q", lesson.Type)
}
}
func TestSampleVariables(t *testing.T) {
if len(SampleAnswers) == 0 {
t.Error("SampleAnswers should not be empty")
}
if len(SampleSubItems) == 0 {
t.Error("SampleSubItems should not be empty")
}
// Verify one correct answer exists in SampleAnswers
hasCorrect := false
for _, a := range SampleAnswers {
if a.Correct {
hasCorrect = true
break
}
}
if !hasCorrect {
t.Error("SampleAnswers should have at least one correct answer")
}
}