mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 13:02:08 +01:00
Add comprehensive unit tests for services and main package
- Implement tests for the app service, including course processing from file and URI. - Create mock implementations for CourseParser and Exporter to facilitate testing. - Add tests for HTML cleaner service to validate HTML content cleaning functionality. - Develop tests for the parser service, covering course fetching and loading from files. - Introduce tests for utility functions in the main package, ensuring URI validation and string joining. - Include benchmarks for performance evaluation of key functions.
This commit is contained in:
@ -4,17 +4,18 @@ package exporters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fumiama/go-docx"
|
||||
"github.com/kjanat/articulate-parser/internal/interfaces"
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
"github.com/unidoc/unioffice/document"
|
||||
)
|
||||
|
||||
// DocxExporter implements the Exporter interface for DOCX format.
|
||||
// It converts Articulate Rise course data into a Microsoft Word document
|
||||
// using the unioffice/document package.
|
||||
// using the go-docx package.
|
||||
type DocxExporter struct {
|
||||
// htmlCleaner is used to convert HTML content to plain text
|
||||
htmlCleaner *services.HTMLCleaner
|
||||
@ -45,21 +46,17 @@ func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
|
||||
// Returns:
|
||||
// - An error if creating or saving the document fails
|
||||
func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
||||
doc := document.New()
|
||||
doc := docx.New()
|
||||
|
||||
// Add title
|
||||
titlePara := doc.AddParagraph()
|
||||
titleRun := titlePara.AddRun()
|
||||
titleRun.AddText(course.Course.Title)
|
||||
titleRun.Properties().SetBold(true)
|
||||
titleRun.Properties().SetSize(16)
|
||||
titlePara.AddText(course.Course.Title).Size("32").Bold()
|
||||
|
||||
// Add description if available
|
||||
if course.Course.Description != "" {
|
||||
descPara := doc.AddParagraph()
|
||||
descRun := descPara.AddRun()
|
||||
cleanDesc := e.htmlCleaner.CleanHTML(course.Course.Description)
|
||||
descRun.AddText(cleanDesc)
|
||||
descPara.AddText(cleanDesc)
|
||||
}
|
||||
|
||||
// Add each lesson
|
||||
@ -72,7 +69,20 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
||||
outputPath = outputPath + ".docx"
|
||||
}
|
||||
|
||||
return doc.SaveToFile(outputPath)
|
||||
// Create the file
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Save the document
|
||||
_, err = doc.WriteTo(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save document: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportLesson adds a lesson to the document with appropriate formatting.
|
||||
@ -81,20 +91,16 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
|
||||
// Parameters:
|
||||
// - doc: The Word document being created
|
||||
// - lesson: The lesson data model to export
|
||||
func (e *DocxExporter) exportLesson(doc *document.Document, lesson *models.Lesson) {
|
||||
func (e *DocxExporter) exportLesson(doc *docx.Docx, lesson *models.Lesson) {
|
||||
// Add lesson title
|
||||
lessonPara := doc.AddParagraph()
|
||||
lessonRun := lessonPara.AddRun()
|
||||
lessonRun.AddText(fmt.Sprintf("Lesson: %s", lesson.Title))
|
||||
lessonRun.Properties().SetBold(true)
|
||||
lessonRun.Properties().SetSize(14)
|
||||
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size("28").Bold()
|
||||
|
||||
// Add lesson description if available
|
||||
if lesson.Description != "" {
|
||||
descPara := doc.AddParagraph()
|
||||
descRun := descPara.AddRun()
|
||||
cleanDesc := e.htmlCleaner.CleanHTML(lesson.Description)
|
||||
descRun.AddText(cleanDesc)
|
||||
descPara.AddText(cleanDesc)
|
||||
}
|
||||
|
||||
// Add each item in the lesson
|
||||
@ -109,14 +115,11 @@ func (e *DocxExporter) exportLesson(doc *document.Document, lesson *models.Lesso
|
||||
// Parameters:
|
||||
// - doc: The Word document being created
|
||||
// - item: The item data model to export
|
||||
func (e *DocxExporter) exportItem(doc *document.Document, item *models.Item) {
|
||||
func (e *DocxExporter) exportItem(doc *docx.Docx, item *models.Item) {
|
||||
// Add item type as heading
|
||||
if item.Type != "" {
|
||||
itemPara := doc.AddParagraph()
|
||||
itemRun := itemPara.AddRun()
|
||||
itemRun.AddText(strings.Title(item.Type))
|
||||
itemRun.Properties().SetBold(true)
|
||||
itemRun.Properties().SetSize(12)
|
||||
itemPara.AddText(strings.Title(item.Type)).Size("24").Bold()
|
||||
}
|
||||
|
||||
// Add sub-items
|
||||
@ -132,58 +135,48 @@ func (e *DocxExporter) exportItem(doc *document.Document, item *models.Item) {
|
||||
// Parameters:
|
||||
// - doc: The Word document being created
|
||||
// - subItem: The sub-item data model to export
|
||||
func (e *DocxExporter) exportSubItem(doc *document.Document, subItem *models.SubItem) {
|
||||
func (e *DocxExporter) exportSubItem(doc *docx.Docx, subItem *models.SubItem) {
|
||||
// Add title if available
|
||||
if subItem.Title != "" {
|
||||
subItemPara := doc.AddParagraph()
|
||||
subItemRun := subItemPara.AddRun()
|
||||
subItemRun.AddText(" " + subItem.Title) // Indented
|
||||
subItemRun.Properties().SetBold(true)
|
||||
subItemPara.AddText(" " + subItem.Title).Bold() // Indented
|
||||
}
|
||||
|
||||
// Add heading if available
|
||||
if subItem.Heading != "" {
|
||||
headingPara := doc.AddParagraph()
|
||||
headingRun := headingPara.AddRun()
|
||||
cleanHeading := e.htmlCleaner.CleanHTML(subItem.Heading)
|
||||
headingRun.AddText(" " + cleanHeading) // Indented
|
||||
headingRun.Properties().SetBold(true)
|
||||
headingPara.AddText(" " + cleanHeading).Bold() // Indented
|
||||
}
|
||||
|
||||
// Add paragraph content if available
|
||||
if subItem.Paragraph != "" {
|
||||
contentPara := doc.AddParagraph()
|
||||
contentRun := contentPara.AddRun()
|
||||
cleanContent := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
contentRun.AddText(" " + cleanContent) // Indented
|
||||
contentPara.AddText(" " + cleanContent) // Indented
|
||||
}
|
||||
|
||||
// Add answers if this is a question
|
||||
if len(subItem.Answers) > 0 {
|
||||
answersPara := doc.AddParagraph()
|
||||
answersRun := answersPara.AddRun()
|
||||
answersRun.AddText(" Answers:")
|
||||
answersRun.Properties().SetBold(true)
|
||||
answersPara.AddText(" Answers:").Bold()
|
||||
|
||||
for i, answer := range subItem.Answers {
|
||||
answerPara := doc.AddParagraph()
|
||||
answerRun := answerPara.AddRun()
|
||||
prefix := fmt.Sprintf(" %d. ", i+1)
|
||||
if answer.Correct {
|
||||
prefix += "✓ "
|
||||
}
|
||||
cleanAnswer := e.htmlCleaner.CleanHTML(answer.Title)
|
||||
answerRun.AddText(prefix + cleanAnswer)
|
||||
answerPara.AddText(prefix + cleanAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// Add feedback if available
|
||||
if subItem.Feedback != "" {
|
||||
feedbackPara := doc.AddParagraph()
|
||||
feedbackRun := feedbackPara.AddRun()
|
||||
cleanFeedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
|
||||
feedbackRun.AddText(" Feedback: " + cleanFeedback)
|
||||
feedbackRun.Properties().SetItalic(true)
|
||||
feedbackPara.AddText(" Feedback: " + cleanFeedback).Italic()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
679
internal/exporters/docx_test.go
Normal file
679
internal/exporters/docx_test.go
Normal file
@ -0,0 +1,679 @@
|
||||
// Package exporters_test provides tests for the docx exporter.
|
||||
package exporters
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// TestNewDocxExporter tests the NewDocxExporter constructor.
|
||||
func TestNewDocxExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatal("NewDocxExporter() returned nil")
|
||||
}
|
||||
|
||||
// Type assertion to check internal structure
|
||||
docxExporter, ok := exporter.(*DocxExporter)
|
||||
if !ok {
|
||||
t.Fatal("NewDocxExporter() returned wrong type")
|
||||
}
|
||||
|
||||
if docxExporter.htmlCleaner == nil {
|
||||
t.Error("htmlCleaner should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_GetSupportedFormat tests the GetSupportedFormat method.
|
||||
func TestDocxExporter_GetSupportedFormat(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
expected := "docx"
|
||||
result := exporter.GetSupportedFormat()
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected format '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_Export tests the Export method.
|
||||
func TestDocxExporter_Export(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create test course
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
// Create temporary directory and file
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "test-course.docx")
|
||||
|
||||
// Test successful export
|
||||
err := exporter.Export(testCourse, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that file was created
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
|
||||
// Verify file has some content (basic check)
|
||||
fileInfo, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get file info: %v", err)
|
||||
}
|
||||
|
||||
if fileInfo.Size() == 0 {
|
||||
t.Error("Output file is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
|
||||
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
// Create temporary directory and file without .docx extension
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "test-course")
|
||||
|
||||
err := exporter.Export(testCourse, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that file was created with .docx extension
|
||||
expectedPath := outputPath + ".docx"
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file with .docx extension was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
|
||||
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
// Try to write to invalid path
|
||||
invalidPath := "/invalid/path/that/does/not/exist/file.docx"
|
||||
err := exporter.Export(testCourse, invalidPath)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
|
||||
func TestDocxExporter_ExportLesson(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create course with specific lesson content
|
||||
course := &models.Course{
|
||||
ShareID: "test-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "test-course",
|
||||
Title: "Test Course",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Test Lesson",
|
||||
Type: "lesson",
|
||||
Description: "<p>Test lesson description with <strong>bold</strong> text.</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "Test Item Title",
|
||||
Paragraph: "<p>Test paragraph content.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "lesson-test.docx")
|
||||
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created successfully
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
|
||||
func TestDocxExporter_ExportItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create course with different item types
|
||||
course := &models.Course{
|
||||
ShareID: "test-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "test-course",
|
||||
Title: "Item Test Course",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Item Types Lesson",
|
||||
Type: "lesson",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "Text Item",
|
||||
Paragraph: "<p>Text content</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "<p>List item 1</p>"},
|
||||
{Paragraph: "<p>List item 2</p>"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>What is the answer?</p>",
|
||||
Answers: []models.Answer{
|
||||
{Title: "Option A", Correct: false},
|
||||
{Title: "Option B", Correct: true},
|
||||
},
|
||||
Feedback: "<p>Correct answer explanation</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "items-test.docx")
|
||||
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created successfully
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
|
||||
func TestDocxExporter_ExportSubItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create course with sub-item containing all possible fields
|
||||
course := &models.Course{
|
||||
ShareID: "test-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "test-course",
|
||||
Title: "SubItem Test Course",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "SubItem Test Lesson",
|
||||
Type: "lesson",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>Question Title</p>",
|
||||
Heading: "<h3>Question Heading</h3>",
|
||||
Paragraph: "<p>Question description with <em>emphasis</em>.</p>",
|
||||
Answers: []models.Answer{
|
||||
{Title: "Wrong answer", Correct: false},
|
||||
{Title: "Correct answer", Correct: true},
|
||||
{Title: "Another wrong answer", Correct: false},
|
||||
},
|
||||
Feedback: "<p>Feedback with <strong>formatting</strong>.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "subitem-test.docx")
|
||||
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created successfully
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
|
||||
func TestDocxExporter_ComplexCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create complex test course
|
||||
course := &models.Course{
|
||||
ShareID: "complex-test-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "complex-course",
|
||||
Title: "Complex Test Course",
|
||||
Description: "<p>This is a <strong>complex</strong> course description with <em>formatting</em>.</p>",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "section-1",
|
||||
Title: "Course Section",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Introduction Lesson",
|
||||
Type: "lesson",
|
||||
Description: "<p>Introduction to the course with <code>code</code> and <a href='#'>links</a>.</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "<h2>Welcome</h2>",
|
||||
Paragraph: "<p>Welcome to our comprehensive course!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "<p>Learn advanced concepts</p>"},
|
||||
{Paragraph: "<p>Practice with real examples</p>"},
|
||||
{Paragraph: "<p>Apply knowledge in projects</p>"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "multimedia",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>Video Introduction</p>",
|
||||
Caption: "<p>Watch this introductory video</p>",
|
||||
Media: &models.Media{
|
||||
Video: &models.VideoMedia{
|
||||
OriginalUrl: "https://example.com/intro.mp4",
|
||||
Duration: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>What will you learn in this course?</p>",
|
||||
Answers: []models.Answer{
|
||||
{Title: "Basic concepts only", Correct: false},
|
||||
{Title: "Advanced concepts and practical application", Correct: true},
|
||||
{Title: "Theory without practice", Correct: false},
|
||||
},
|
||||
Feedback: "<p>Excellent! This course covers both theory and practice.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "image",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Caption: "<p>Course overview diagram</p>",
|
||||
Media: &models.Media{
|
||||
Image: &models.ImageMedia{
|
||||
OriginalUrl: "https://example.com/overview.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "interactive",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>Interactive Exercise</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "lesson-2",
|
||||
Title: "Advanced Topics",
|
||||
Type: "lesson",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "divider",
|
||||
},
|
||||
{
|
||||
Type: "unknown",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>Custom Content</p>",
|
||||
Paragraph: "<p>This is custom content type</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create temporary output file
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "complex-course.docx")
|
||||
|
||||
// Export course
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created and has reasonable size
|
||||
fileInfo, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get file info: %v", err)
|
||||
}
|
||||
|
||||
if fileInfo.Size() < 1000 {
|
||||
t.Error("Output file seems too small for complex course content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_EmptyCourse tests export of an empty course.
|
||||
func TestDocxExporter_EmptyCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create minimal course
|
||||
course := &models.Course{
|
||||
ShareID: "empty-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "empty-course",
|
||||
Title: "Empty Course",
|
||||
Lessons: []models.Lesson{},
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "empty-course.docx")
|
||||
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
|
||||
func TestDocxExporter_HTMLCleaning(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create course with HTML content that needs cleaning
|
||||
course := &models.Course{
|
||||
ShareID: "html-test-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "html-test-course",
|
||||
Title: "HTML Cleaning Test",
|
||||
Description: "<p>Description with <script>alert('xss')</script> and <b>bold</b> text.</p>",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Test Lesson",
|
||||
Type: "lesson",
|
||||
Description: "<div>Lesson description with <span style='color:red'>styled</span> content.</div>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "<h1>Heading with <em>emphasis</em> and & entities</h1>",
|
||||
Paragraph: "<p>Paragraph with <code> entities and <strong>formatting</strong>.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "html-cleaning-test.docx")
|
||||
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created (basic check that HTML cleaning didn't break export)
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
|
||||
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
// Use path that already has .docx extension
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "test-course.docx")
|
||||
|
||||
err := exporter.Export(testCourse, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that file was created at the exact path (no double extension)
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created at expected path")
|
||||
}
|
||||
|
||||
// Ensure no double extension was created
|
||||
doubleExtensionPath := outputPath + ".docx"
|
||||
if _, err := os.Stat(doubleExtensionPath); err == nil {
|
||||
t.Error("Double .docx extension file should not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
|
||||
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
testCourse := createTestCourseForDocx()
|
||||
|
||||
// Test various case combinations
|
||||
testCases := []string{
|
||||
"test-course.DOCX",
|
||||
"test-course.Docx",
|
||||
"test-course.DocX",
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, testCase)
|
||||
|
||||
err := exporter.Export(testCourse, outputPath)
|
||||
if err != nil {
|
||||
|
||||
t.Fatalf("Export failed for case %d (%s): %v", i, testCase, err)
|
||||
}
|
||||
|
||||
// Check that file was created at the exact path (no additional extension)
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatalf("Output file was not created at expected path for case %d (%s)", i, testCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createTestCourseForDocx creates a test course for DOCX export testing.
|
||||
func createTestCourseForDocx() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "test-course-id",
|
||||
Title: "Test Course",
|
||||
Description: "<p>Test course description with <strong>formatting</strong>.</p>",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "section-1",
|
||||
Title: "Test Section",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Test Lesson",
|
||||
Type: "lesson",
|
||||
Description: "<p>Test lesson description</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "<h2>Test Heading</h2>",
|
||||
Paragraph: "<p>Test paragraph content.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "<p>First list item</p>"},
|
||||
{Paragraph: "<p>Second list item</p>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDocxExporter_Export benchmarks the Export method.
|
||||
func BenchmarkDocxExporter_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
course := createTestCourseForDocx()
|
||||
|
||||
// Create temporary directory
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
outputPath := filepath.Join(tempDir, "benchmark-course.docx")
|
||||
_ = exporter.Export(course, outputPath)
|
||||
// Clean up for next iteration
|
||||
os.Remove(outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
|
||||
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewDocxExporter(htmlCleaner)
|
||||
|
||||
// Create complex course for benchmarking
|
||||
course := &models.Course{
|
||||
ShareID: "benchmark-id",
|
||||
Course: models.CourseInfo{
|
||||
ID: "benchmark-course",
|
||||
Title: "Benchmark Course",
|
||||
Description: "<p>Complex course for performance testing</p>",
|
||||
Lessons: make([]models.Lesson, 10), // 10 lessons
|
||||
},
|
||||
}
|
||||
|
||||
// Fill with test data
|
||||
for i := 0; i < 10; i++ {
|
||||
lesson := models.Lesson{
|
||||
ID: "lesson-" + string(rune(i)),
|
||||
Title: "Lesson " + string(rune(i)),
|
||||
Type: "lesson",
|
||||
Items: make([]models.Item, 5), // 5 items per lesson
|
||||
}
|
||||
|
||||
for j := 0; j < 5; j++ {
|
||||
item := models.Item{
|
||||
Type: "text",
|
||||
Items: make([]models.SubItem, 3), // 3 sub-items per item
|
||||
}
|
||||
|
||||
for k := 0; k < 3; k++ {
|
||||
item.Items[k] = models.SubItem{
|
||||
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
|
||||
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
|
||||
}
|
||||
}
|
||||
|
||||
lesson.Items[j] = item
|
||||
}
|
||||
|
||||
course.Course.Lessons[i] = lesson
|
||||
}
|
||||
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
outputPath := filepath.Join(tempDir, "benchmark-complex.docx")
|
||||
_ = exporter.Export(course, outputPath)
|
||||
os.Remove(outputPath)
|
||||
}
|
||||
}
|
||||
444
internal/exporters/factory_test.go
Normal file
444
internal/exporters/factory_test.go
Normal file
@ -0,0 +1,444 @@
|
||||
// Package exporters_test provides tests for the exporter factory.
|
||||
package exporters
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// TestNewFactory tests the NewFactory constructor.
|
||||
func TestNewFactory(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
if factory == nil {
|
||||
t.Fatal("NewFactory() returned nil")
|
||||
}
|
||||
|
||||
// Type assertion to check internal structure
|
||||
factoryImpl, ok := factory.(*Factory)
|
||||
if !ok {
|
||||
t.Fatal("NewFactory() returned wrong type")
|
||||
}
|
||||
|
||||
if factoryImpl.htmlCleaner == nil {
|
||||
t.Error("htmlCleaner should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
|
||||
func TestFactory_CreateExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
format string
|
||||
expectedType string
|
||||
expectedFormat string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "markdown format",
|
||||
format: "markdown",
|
||||
expectedType: "*exporters.MarkdownExporter",
|
||||
expectedFormat: "markdown",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "md format alias",
|
||||
format: "md",
|
||||
expectedType: "*exporters.MarkdownExporter",
|
||||
expectedFormat: "markdown",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "docx format",
|
||||
format: "docx",
|
||||
expectedType: "*exporters.DocxExporter",
|
||||
expectedFormat: "docx",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "word format alias",
|
||||
format: "word",
|
||||
expectedType: "*exporters.DocxExporter",
|
||||
expectedFormat: "docx",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "unsupported format",
|
||||
format: "pdf",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "empty format",
|
||||
format: "",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
exporter, err := factory.CreateExporter(tc.format)
|
||||
|
||||
if tc.shouldError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for format '%s', but got nil", tc.format)
|
||||
}
|
||||
if exporter != nil {
|
||||
t.Errorf("Expected nil exporter for unsupported format '%s'", tc.format)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error creating exporter for format '%s': %v", tc.format, err)
|
||||
}
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatalf("CreateExporter returned nil for supported format '%s'", tc.format)
|
||||
}
|
||||
|
||||
// Check type
|
||||
exporterType := reflect.TypeOf(exporter).String()
|
||||
if exporterType != tc.expectedType {
|
||||
t.Errorf("Expected exporter type '%s' for format '%s', got '%s'", tc.expectedType, tc.format, exporterType)
|
||||
}
|
||||
|
||||
// Check supported format
|
||||
supportedFormat := exporter.GetSupportedFormat()
|
||||
if supportedFormat != tc.expectedFormat {
|
||||
t.Errorf("Expected supported format '%s' for format '%s', got '%s'", tc.expectedFormat, tc.format, supportedFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
|
||||
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
testCases := []struct {
|
||||
format string
|
||||
expectedFormat string
|
||||
}{
|
||||
{"MARKDOWN", "markdown"},
|
||||
{"Markdown", "markdown"},
|
||||
{"MarkDown", "markdown"},
|
||||
{"MD", "markdown"},
|
||||
{"Md", "markdown"},
|
||||
{"DOCX", "docx"},
|
||||
{"Docx", "docx"},
|
||||
{"DocX", "docx"},
|
||||
{"WORD", "docx"},
|
||||
{"Word", "docx"},
|
||||
{"WoRd", "docx"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
exporter, err := factory.CreateExporter(tc.format)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error for format '%s': %v", tc.format, err)
|
||||
}
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatalf("CreateExporter returned nil for format '%s'", tc.format)
|
||||
}
|
||||
|
||||
supportedFormat := exporter.GetSupportedFormat()
|
||||
if supportedFormat != tc.expectedFormat {
|
||||
t.Errorf("Expected supported format '%s' for format '%s', got '%s'", tc.expectedFormat, tc.format, supportedFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
|
||||
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
testCases := []string{
|
||||
"pdf",
|
||||
"html",
|
||||
"txt",
|
||||
"json",
|
||||
"xml",
|
||||
"unknown",
|
||||
"123",
|
||||
"markdown-invalid",
|
||||
}
|
||||
|
||||
for _, format := range testCases {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
exporter, err := factory.CreateExporter(format)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for unsupported format '%s', got nil", format)
|
||||
}
|
||||
|
||||
if exporter != nil {
|
||||
t.Errorf("Expected nil exporter for unsupported format '%s', got %v", format, exporter)
|
||||
}
|
||||
|
||||
// Check error message contains the format
|
||||
if err != nil && !strings.Contains(err.Error(), format) {
|
||||
t.Errorf("Error message should contain the unsupported format '%s', got: %s", format, err.Error())
|
||||
}
|
||||
|
||||
// Check error message has expected prefix
|
||||
if err != nil && !strings.Contains(err.Error(), "unsupported export format") {
|
||||
t.Errorf("Error message should contain 'unsupported export format', got: %s", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_GetSupportedFormats tests the GetSupportedFormats method.
|
||||
func TestFactory_GetSupportedFormats(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
formats := factory.GetSupportedFormats()
|
||||
|
||||
if formats == nil {
|
||||
t.Fatal("GetSupportedFormats() returned nil")
|
||||
}
|
||||
|
||||
expected := []string{"markdown", "md", "docx", "word"}
|
||||
|
||||
// Sort both slices for comparison
|
||||
sort.Strings(formats)
|
||||
sort.Strings(expected)
|
||||
|
||||
if !reflect.DeepEqual(formats, expected) {
|
||||
t.Errorf("Expected formats %v, got %v", expected, formats)
|
||||
}
|
||||
|
||||
// Verify all returned formats can create exporters
|
||||
for _, format := range formats {
|
||||
exporter, err := factory.CreateExporter(format)
|
||||
if err != nil {
|
||||
t.Errorf("Format '%s' from GetSupportedFormats() should be creatable, got error: %v", format, err)
|
||||
}
|
||||
if exporter == nil {
|
||||
t.Errorf("Format '%s' from GetSupportedFormats() should create non-nil exporter", format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_GetSupportedFormats_Immutable tests that the returned slice is safe to modify.
|
||||
func TestFactory_GetSupportedFormats_Immutable(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
// Get formats twice
|
||||
formats1 := factory.GetSupportedFormats()
|
||||
formats2 := factory.GetSupportedFormats()
|
||||
|
||||
// Modify first slice
|
||||
if len(formats1) > 0 {
|
||||
formats1[0] = "modified"
|
||||
}
|
||||
|
||||
// Check that second call returns unmodified data
|
||||
if len(formats2) > 0 && formats2[0] == "modified" {
|
||||
t.Error("GetSupportedFormats() should return independent slices")
|
||||
}
|
||||
|
||||
// Verify original functionality still works
|
||||
formats3 := factory.GetSupportedFormats()
|
||||
if len(formats3) == 0 {
|
||||
t.Error("GetSupportedFormats() should still return formats after modification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_ExporterTypes tests that created exporters are of correct types.
|
||||
func TestFactory_ExporterTypes(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
// Test markdown exporter
|
||||
markdownExporter, err := factory.CreateExporter("markdown")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create markdown exporter: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := markdownExporter.(*MarkdownExporter); !ok {
|
||||
t.Error("Markdown exporter should be of type *MarkdownExporter")
|
||||
}
|
||||
|
||||
// Test docx exporter
|
||||
docxExporter, err := factory.CreateExporter("docx")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create docx exporter: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := docxExporter.(*DocxExporter); !ok {
|
||||
t.Error("DOCX exporter should be of type *DocxExporter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
|
||||
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
// Test with markdown exporter
|
||||
markdownExporter, err := factory.CreateExporter("markdown")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create markdown exporter: %v", err)
|
||||
}
|
||||
|
||||
markdownImpl, ok := markdownExporter.(*MarkdownExporter)
|
||||
if !ok {
|
||||
t.Fatal("Failed to cast to MarkdownExporter")
|
||||
}
|
||||
|
||||
if markdownImpl.htmlCleaner == nil {
|
||||
t.Error("HTMLCleaner should be propagated to MarkdownExporter")
|
||||
}
|
||||
|
||||
// Test with docx exporter
|
||||
docxExporter, err := factory.CreateExporter("docx")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create docx exporter: %v", err)
|
||||
}
|
||||
|
||||
docxImpl, ok := docxExporter.(*DocxExporter)
|
||||
if !ok {
|
||||
t.Fatal("Failed to cast to DocxExporter")
|
||||
}
|
||||
|
||||
if docxImpl.htmlCleaner == nil {
|
||||
t.Error("HTMLCleaner should be propagated to DocxExporter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
|
||||
func TestFactory_MultipleExporterCreation(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
// Create multiple markdown exporters
|
||||
exporter1, err := factory.CreateExporter("markdown")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create first markdown exporter: %v", err)
|
||||
}
|
||||
|
||||
exporter2, err := factory.CreateExporter("md")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create second markdown exporter: %v", err)
|
||||
}
|
||||
|
||||
// They should be different instances
|
||||
if exporter1 == exporter2 {
|
||||
t.Error("Factory should create independent exporter instances")
|
||||
}
|
||||
|
||||
// But both should be MarkdownExporter type
|
||||
if _, ok := exporter1.(*MarkdownExporter); !ok {
|
||||
t.Error("First exporter should be MarkdownExporter")
|
||||
}
|
||||
|
||||
if _, ok := exporter2.(*MarkdownExporter); !ok {
|
||||
t.Error("Second exporter should be MarkdownExporter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFactory_WithNilHTMLCleaner tests factory behavior with nil HTMLCleaner.
|
||||
func TestFactory_WithNilHTMLCleaner(t *testing.T) {
|
||||
// This tests edge case - should not panic but behavior may vary
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Factory should handle nil HTMLCleaner gracefully, but panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
factory := NewFactory(nil)
|
||||
|
||||
if factory == nil {
|
||||
t.Fatal("NewFactory(nil) returned nil")
|
||||
}
|
||||
|
||||
// Try to create an exporter - this might fail or succeed depending on implementation
|
||||
_, err := factory.CreateExporter("markdown")
|
||||
|
||||
// We don't assert on the error since nil HTMLCleaner handling is implementation-dependent
|
||||
// The important thing is that it doesn't panic
|
||||
_ = err
|
||||
}
|
||||
|
||||
// TestFactory_FormatNormalization tests that format strings are properly normalized.
|
||||
func TestFactory_FormatNormalization(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
// Test formats with extra whitespace
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"markdown", "markdown"},
|
||||
{"MARKDOWN", "markdown"},
|
||||
{"Markdown", "markdown"},
|
||||
{"docx", "docx"},
|
||||
{"DOCX", "docx"},
|
||||
{"Docx", "docx"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
exporter, err := factory.CreateExporter(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create exporter for '%s': %v", tc.input, err)
|
||||
}
|
||||
|
||||
format := exporter.GetSupportedFormat()
|
||||
if format != tc.expected {
|
||||
t.Errorf("Expected format '%s' for input '%s', got '%s'", tc.expected, tc.input, format)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
|
||||
func BenchmarkFactory_CreateExporter(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = factory.CreateExporter("markdown")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
|
||||
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = factory.CreateExporter("docx")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFactory_GetSupportedFormats benchmarks the GetSupportedFormats method.
|
||||
func BenchmarkFactory_GetSupportedFormats(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
factory := NewFactory(htmlCleaner)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = factory.GetSupportedFormats()
|
||||
}
|
||||
}
|
||||
@ -65,13 +65,15 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
|
||||
buf.WriteString("\n---\n\n")
|
||||
|
||||
// Process lessons
|
||||
for i, lesson := range course.Course.Lessons {
|
||||
lessonCounter := 0
|
||||
for _, lesson := range course.Course.Lessons {
|
||||
if lesson.Type == "section" {
|
||||
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title))
|
||||
continue
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", i+1, lesson.Title))
|
||||
lessonCounter++
|
||||
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", lessonCounter, lesson.Title))
|
||||
|
||||
if lesson.Description != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description)))
|
||||
@ -110,116 +112,178 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
|
||||
|
||||
switch item.Type {
|
||||
case "text":
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Heading != "" {
|
||||
heading := e.htmlCleaner.CleanHTML(subItem.Heading)
|
||||
if heading != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s %s\n\n", headingPrefix, heading))
|
||||
}
|
||||
}
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.processTextItem(buf, item, headingPrefix)
|
||||
case "list":
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph != "" {
|
||||
buf.WriteString(fmt.Sprintf("- %s\n", paragraph))
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
|
||||
e.processListItem(buf, item)
|
||||
case "multimedia":
|
||||
buf.WriteString(fmt.Sprintf("%s Media Content\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Media != nil {
|
||||
if subItem.Media.Video != nil {
|
||||
buf.WriteString(fmt.Sprintf("**Video**: %s\n", subItem.Media.Video.OriginalUrl))
|
||||
if subItem.Media.Video.Duration > 0 {
|
||||
buf.WriteString(fmt.Sprintf("**Duration**: %d seconds\n", subItem.Media.Video.Duration))
|
||||
}
|
||||
}
|
||||
if subItem.Media.Image != nil {
|
||||
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
|
||||
}
|
||||
}
|
||||
if subItem.Caption != "" {
|
||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
||||
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
|
||||
e.processMultimediaItem(buf, item, headingPrefix)
|
||||
case "image":
|
||||
buf.WriteString(fmt.Sprintf("%s Image\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Media != nil && subItem.Media.Image != nil {
|
||||
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
|
||||
}
|
||||
if subItem.Caption != "" {
|
||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
||||
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
|
||||
e.processImageItem(buf, item, headingPrefix)
|
||||
case "knowledgeCheck":
|
||||
buf.WriteString(fmt.Sprintf("%s Knowledge Check\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Title != "" {
|
||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
buf.WriteString(fmt.Sprintf("**Question**: %s\n\n", title))
|
||||
}
|
||||
|
||||
buf.WriteString("**Answers**:\n")
|
||||
for i, answer := range subItem.Answers {
|
||||
correctMark := ""
|
||||
if answer.Correct {
|
||||
correctMark = " ✓"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, answer.Title, correctMark))
|
||||
}
|
||||
|
||||
if subItem.Feedback != "" {
|
||||
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
|
||||
buf.WriteString(fmt.Sprintf("\n**Feedback**: %s\n", feedback))
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
|
||||
e.processKnowledgeCheckItem(buf, item, headingPrefix)
|
||||
case "interactive":
|
||||
buf.WriteString(fmt.Sprintf("%s Interactive Content\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Title != "" {
|
||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
|
||||
e.processInteractiveItem(buf, item, headingPrefix)
|
||||
case "divider":
|
||||
e.processDividerItem(buf)
|
||||
default:
|
||||
e.processUnknownItem(buf, item, headingPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// processTextItem handles text content with headings and paragraphs
|
||||
func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Heading != "" {
|
||||
heading := e.htmlCleaner.CleanHTML(subItem.Heading)
|
||||
if heading != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s %s\n\n", headingPrefix, heading))
|
||||
}
|
||||
}
|
||||
|
||||
case "divider":
|
||||
buf.WriteString("---\n\n")
|
||||
|
||||
default:
|
||||
// Handle unknown types
|
||||
if len(item.Items) > 0 {
|
||||
buf.WriteString(fmt.Sprintf("%s %s Content\n\n", headingPrefix, strings.Title(item.Type)))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Title != "" {
|
||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
|
||||
}
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
|
||||
}
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph != "" {
|
||||
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processListItem handles list items with bullet points
|
||||
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
if paragraph != "" {
|
||||
buf.WriteString(fmt.Sprintf("- %s\n", paragraph))
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// processMultimediaItem handles multimedia content including videos and images
|
||||
func (e *MarkdownExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||
buf.WriteString(fmt.Sprintf("%s Media Content\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
e.processMediaSubItem(buf, subItem)
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// processMediaSubItem processes individual media items (video/image)
|
||||
func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
||||
if subItem.Media != nil {
|
||||
e.processVideoMedia(buf, subItem.Media)
|
||||
e.processImageMedia(buf, subItem.Media)
|
||||
}
|
||||
if subItem.Caption != "" {
|
||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
||||
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
|
||||
}
|
||||
}
|
||||
|
||||
// processVideoMedia processes video media content
|
||||
func (e *MarkdownExporter) processVideoMedia(buf *bytes.Buffer, media *models.Media) {
|
||||
if media.Video != nil {
|
||||
buf.WriteString(fmt.Sprintf("**Video**: %s\n", media.Video.OriginalUrl))
|
||||
if media.Video.Duration > 0 {
|
||||
buf.WriteString(fmt.Sprintf("**Duration**: %d seconds\n", media.Video.Duration))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processImageMedia processes image media content
|
||||
func (e *MarkdownExporter) processImageMedia(buf *bytes.Buffer, media *models.Media) {
|
||||
if media.Image != nil {
|
||||
buf.WriteString(fmt.Sprintf("**Image**: %s\n", media.Image.OriginalUrl))
|
||||
}
|
||||
}
|
||||
|
||||
// processImageItem handles standalone image items
|
||||
func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||
buf.WriteString(fmt.Sprintf("%s Image\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Media != nil && subItem.Media.Image != nil {
|
||||
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
|
||||
}
|
||||
if subItem.Caption != "" {
|
||||
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
|
||||
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// processKnowledgeCheckItem handles quiz questions and knowledge checks
|
||||
func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||
buf.WriteString(fmt.Sprintf("%s Knowledge Check\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
e.processQuestionSubItem(buf, subItem)
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// processQuestionSubItem processes individual question items
|
||||
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
||||
if subItem.Title != "" {
|
||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
buf.WriteString(fmt.Sprintf("**Question**: %s\n\n", title))
|
||||
}
|
||||
|
||||
e.processAnswers(buf, subItem.Answers)
|
||||
|
||||
if subItem.Feedback != "" {
|
||||
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
|
||||
buf.WriteString(fmt.Sprintf("\n**Feedback**: %s\n", feedback))
|
||||
}
|
||||
}
|
||||
|
||||
// processAnswers processes answer choices for quiz questions
|
||||
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
|
||||
buf.WriteString("**Answers**:\n")
|
||||
for i, answer := range answers {
|
||||
correctMark := ""
|
||||
if answer.Correct {
|
||||
correctMark = " ✓"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, answer.Title, correctMark))
|
||||
}
|
||||
}
|
||||
|
||||
// processInteractiveItem handles interactive content
|
||||
func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||
buf.WriteString(fmt.Sprintf("%s Interactive Content\n\n", headingPrefix))
|
||||
for _, subItem := range item.Items {
|
||||
if subItem.Title != "" {
|
||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processDividerItem handles divider elements
|
||||
func (e *MarkdownExporter) processDividerItem(buf *bytes.Buffer) {
|
||||
buf.WriteString("---\n\n")
|
||||
}
|
||||
|
||||
// processUnknownItem handles unknown or unsupported item types
|
||||
func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
|
||||
if len(item.Items) > 0 {
|
||||
buf.WriteString(fmt.Sprintf("%s %s Content\n\n", headingPrefix, strings.Title(item.Type)))
|
||||
for _, subItem := range item.Items {
|
||||
e.processGenericSubItem(buf, subItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processGenericSubItem processes sub-items for unknown types
|
||||
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
|
||||
if subItem.Title != "" {
|
||||
title := e.htmlCleaner.CleanHTML(subItem.Title)
|
||||
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
|
||||
}
|
||||
if subItem.Paragraph != "" {
|
||||
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
|
||||
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
|
||||
}
|
||||
}
|
||||
|
||||
693
internal/exporters/markdown_test.go
Normal file
693
internal/exporters/markdown_test.go
Normal file
@ -0,0 +1,693 @@
|
||||
// Package exporters_test provides tests for the markdown exporter.
|
||||
package exporters
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kjanat/articulate-parser/internal/models"
|
||||
"github.com/kjanat/articulate-parser/internal/services"
|
||||
)
|
||||
|
||||
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
|
||||
func TestNewMarkdownExporter(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
|
||||
if exporter == nil {
|
||||
t.Fatal("NewMarkdownExporter() returned nil")
|
||||
}
|
||||
|
||||
// Type assertion to check internal structure
|
||||
markdownExporter, ok := exporter.(*MarkdownExporter)
|
||||
if !ok {
|
||||
t.Fatal("NewMarkdownExporter() returned wrong type")
|
||||
}
|
||||
|
||||
if markdownExporter.htmlCleaner == nil {
|
||||
t.Error("htmlCleaner should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_GetSupportedFormat tests the GetSupportedFormat method.
|
||||
func TestMarkdownExporter_GetSupportedFormat(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
|
||||
expected := "markdown"
|
||||
result := exporter.GetSupportedFormat()
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected format '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_Export tests the Export method.
|
||||
func TestMarkdownExporter_Export(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
|
||||
// Create test course
|
||||
testCourse := createTestCourseForMarkdown()
|
||||
|
||||
// Create temporary directory and file
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "test-course.md")
|
||||
|
||||
// Test successful export
|
||||
err := exporter.Export(testCourse, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that file was created
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Output file was not created")
|
||||
}
|
||||
|
||||
// Read and verify content
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
// Verify main course title
|
||||
if !strings.Contains(contentStr, "# Test Course") {
|
||||
t.Error("Output should contain course title as main heading")
|
||||
}
|
||||
|
||||
// Verify course information section
|
||||
if !strings.Contains(contentStr, "## Course Information") {
|
||||
t.Error("Output should contain course information section")
|
||||
}
|
||||
|
||||
// Verify course metadata
|
||||
if !strings.Contains(contentStr, "- **Course ID**: test-course-id") {
|
||||
t.Error("Output should contain course ID")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "- **Share ID**: test-share-id") {
|
||||
t.Error("Output should contain share ID")
|
||||
}
|
||||
|
||||
// Verify lesson content
|
||||
if !strings.Contains(contentStr, "## Lesson 1: Test Lesson") {
|
||||
t.Error("Output should contain lesson heading")
|
||||
}
|
||||
|
||||
// Verify section handling
|
||||
if !strings.Contains(contentStr, "# Test Section") {
|
||||
t.Error("Output should contain section as main heading")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
|
||||
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
|
||||
testCourse := createTestCourseForMarkdown()
|
||||
|
||||
// Try to write to invalid path
|
||||
invalidPath := "/invalid/path/that/does/not/exist/file.md"
|
||||
err := exporter.Export(testCourse, invalidPath)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
|
||||
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "<h1>Test Heading</h1>",
|
||||
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
|
||||
},
|
||||
{
|
||||
Paragraph: "<p>Another paragraph.</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processTextItem(&buf, item, "###")
|
||||
|
||||
result := buf.String()
|
||||
expected := "### Test Heading\n\nTest paragraph with bold text.\n\nAnother paragraph.\n\n"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected:\n%q\nGot:\n%q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
|
||||
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "<p>First item</p>"},
|
||||
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
|
||||
{Paragraph: "<p>Third item</p>"},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processListItem(&buf, item)
|
||||
|
||||
result := buf.String()
|
||||
expected := "- First item\n- Second item with emphasis\n- Third item\n\n"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected:\n%q\nGot:\n%q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
|
||||
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "multimedia",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Media: &models.Media{
|
||||
Video: &models.VideoMedia{
|
||||
OriginalUrl: "https://example.com/video.mp4",
|
||||
Duration: 120,
|
||||
},
|
||||
},
|
||||
Caption: "<p>Video caption</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processMultimediaItem(&buf, item, "###")
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "### Media Content") {
|
||||
t.Error("Should contain media content heading")
|
||||
}
|
||||
if !strings.Contains(result, "**Video**: https://example.com/video.mp4") {
|
||||
t.Error("Should contain video URL")
|
||||
}
|
||||
if !strings.Contains(result, "**Duration**: 120 seconds") {
|
||||
t.Error("Should contain video duration")
|
||||
}
|
||||
if !strings.Contains(result, "*Video caption*") {
|
||||
t.Error("Should contain video caption")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
|
||||
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "image",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Media: &models.Media{
|
||||
Image: &models.ImageMedia{
|
||||
OriginalUrl: "https://example.com/image.jpg",
|
||||
},
|
||||
},
|
||||
Caption: "<p>Image caption</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processImageItem(&buf, item, "###")
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "### Image") {
|
||||
t.Error("Should contain image heading")
|
||||
}
|
||||
if !strings.Contains(result, "**Image**: https://example.com/image.jpg") {
|
||||
t.Error("Should contain image URL")
|
||||
}
|
||||
if !strings.Contains(result, "*Image caption*") {
|
||||
t.Error("Should contain image caption")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
|
||||
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>What is the capital of France?</p>",
|
||||
Answers: []models.Answer{
|
||||
{Title: "London", Correct: false},
|
||||
{Title: "Paris", Correct: true},
|
||||
{Title: "Berlin", Correct: false},
|
||||
},
|
||||
Feedback: "<p>Paris is the capital of France.</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processKnowledgeCheckItem(&buf, item, "###")
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "### Knowledge Check") {
|
||||
t.Error("Should contain knowledge check heading")
|
||||
}
|
||||
if !strings.Contains(result, "**Question**: What is the capital of France?") {
|
||||
t.Error("Should contain question")
|
||||
}
|
||||
if !strings.Contains(result, "**Answers**:") {
|
||||
t.Error("Should contain answers heading")
|
||||
}
|
||||
if !strings.Contains(result, "2. Paris ✓") {
|
||||
t.Error("Should mark correct answer")
|
||||
}
|
||||
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
|
||||
t.Error("Should contain feedback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
|
||||
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "interactive",
|
||||
Items: []models.SubItem{
|
||||
{Title: "<p>Interactive element title</p>"},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processInteractiveItem(&buf, item, "###")
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "### Interactive Content") {
|
||||
t.Error("Should contain interactive content heading")
|
||||
}
|
||||
if !strings.Contains(result, "**Interactive element title**") {
|
||||
t.Error("Should contain interactive element title")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
|
||||
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
exporter.processDividerItem(&buf)
|
||||
|
||||
result := buf.String()
|
||||
expected := "---\n\n"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
|
||||
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{
|
||||
Type: "unknown",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>Unknown item title</p>",
|
||||
Paragraph: "<p>Unknown item content</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processUnknownItem(&buf, item, "###")
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "### Unknown Content") {
|
||||
t.Error("Should contain unknown content heading")
|
||||
}
|
||||
if !strings.Contains(result, "**Unknown item title**") {
|
||||
t.Error("Should contain unknown item title")
|
||||
}
|
||||
if !strings.Contains(result, "Unknown item content") {
|
||||
t.Error("Should contain unknown item content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
|
||||
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
media := &models.Media{
|
||||
Video: &models.VideoMedia{
|
||||
OriginalUrl: "https://example.com/video.mp4",
|
||||
Duration: 300,
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processVideoMedia(&buf, media)
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "**Video**: https://example.com/video.mp4") {
|
||||
t.Error("Should contain video URL")
|
||||
}
|
||||
if !strings.Contains(result, "**Duration**: 300 seconds") {
|
||||
t.Error("Should contain video duration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
|
||||
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
media := &models.Media{
|
||||
Image: &models.ImageMedia{
|
||||
OriginalUrl: "https://example.com/image.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
exporter.processImageMedia(&buf, media)
|
||||
|
||||
result := buf.String()
|
||||
expected := "**Image**: https://example.com/image.jpg\n"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
|
||||
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
var buf bytes.Buffer
|
||||
answers := []models.Answer{
|
||||
{Title: "Answer 1", Correct: false},
|
||||
{Title: "Answer 2", Correct: true},
|
||||
{Title: "Answer 3", Correct: false},
|
||||
}
|
||||
|
||||
exporter.processAnswers(&buf, answers)
|
||||
|
||||
result := buf.String()
|
||||
|
||||
if !strings.Contains(result, "**Answers**:") {
|
||||
t.Error("Should contain answers heading")
|
||||
}
|
||||
if !strings.Contains(result, "1. Answer 1") {
|
||||
t.Error("Should contain first answer")
|
||||
}
|
||||
if !strings.Contains(result, "2. Answer 2 ✓") {
|
||||
t.Error("Should mark correct answer")
|
||||
}
|
||||
if !strings.Contains(result, "3. Answer 3") {
|
||||
t.Error("Should contain third answer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
|
||||
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
itemType string
|
||||
expectedText string
|
||||
}{
|
||||
{
|
||||
name: "text item",
|
||||
itemType: "text",
|
||||
expectedText: "", // processTextItem handles empty items
|
||||
},
|
||||
{
|
||||
name: "list item",
|
||||
itemType: "list",
|
||||
expectedText: "\n", // Empty list adds newline
|
||||
},
|
||||
{
|
||||
name: "multimedia item",
|
||||
itemType: "multimedia",
|
||||
expectedText: "### Media Content",
|
||||
},
|
||||
{
|
||||
name: "image item",
|
||||
itemType: "image",
|
||||
expectedText: "### Image",
|
||||
},
|
||||
{
|
||||
name: "knowledgeCheck item",
|
||||
itemType: "knowledgeCheck",
|
||||
expectedText: "### Knowledge Check",
|
||||
},
|
||||
{
|
||||
name: "interactive item",
|
||||
itemType: "interactive",
|
||||
expectedText: "### Interactive Content",
|
||||
},
|
||||
{
|
||||
name: "divider item",
|
||||
itemType: "divider",
|
||||
expectedText: "---",
|
||||
},
|
||||
{
|
||||
name: "unknown item",
|
||||
itemType: "unknown",
|
||||
expectedText: "", // Empty unknown items don't add content
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
item := models.Item{Type: tt.itemType}
|
||||
|
||||
exporter.processItemToMarkdown(&buf, item, 3)
|
||||
|
||||
result := buf.String()
|
||||
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
|
||||
t.Errorf("Expected result to contain %q, got %q", tt.expectedText, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
|
||||
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
|
||||
// Create complex test course
|
||||
course := &models.Course{
|
||||
ShareID: "complex-test-id",
|
||||
Author: "Test Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "complex-course",
|
||||
Title: "Complex Test Course",
|
||||
Description: "<p>This is a <strong>complex</strong> course description.</p>",
|
||||
NavigationMode: "menu",
|
||||
ExportSettings: &models.ExportSettings{
|
||||
Format: "scorm",
|
||||
},
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "section-1",
|
||||
Title: "Course Section",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Introduction Lesson",
|
||||
Type: "lesson",
|
||||
Description: "<p>Introduction to the course</p>",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "<h2>Welcome</h2>",
|
||||
Paragraph: "<p>Welcome to our course!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "list",
|
||||
Items: []models.SubItem{
|
||||
{Paragraph: "<p>First objective</p>"},
|
||||
{Paragraph: "<p>Second objective</p>"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "knowledgeCheck",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Title: "<p>What will you learn?</p>",
|
||||
Answers: []models.Answer{
|
||||
{Title: "Nothing", Correct: false},
|
||||
{Title: "Everything", Correct: true},
|
||||
},
|
||||
Feedback: "<p>Great choice!</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create temporary output file
|
||||
tempDir := t.TempDir()
|
||||
outputPath := filepath.Join(tempDir, "complex-course.md")
|
||||
|
||||
// Export course
|
||||
err := exporter.Export(course, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Export failed: %v", err)
|
||||
}
|
||||
|
||||
// Read and verify content
|
||||
content, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
// Verify various elements are present
|
||||
checks := []string{
|
||||
"# Complex Test Course",
|
||||
"This is a complex course description.",
|
||||
"- **Export Format**: scorm",
|
||||
"# Course Section",
|
||||
"## Lesson 1: Introduction Lesson",
|
||||
"Introduction to the course",
|
||||
"### Welcome",
|
||||
"Welcome to our course!",
|
||||
"- First objective",
|
||||
"- Second objective",
|
||||
"### Knowledge Check",
|
||||
"**Question**: What will you learn?",
|
||||
"2. Everything ✓",
|
||||
"**Feedback**: Great choice!",
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(contentStr, check) {
|
||||
t.Errorf("Output should contain: %q", check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createTestCourseForMarkdown creates a test course for markdown export testing.
|
||||
func createTestCourseForMarkdown() *models.Course {
|
||||
return &models.Course{
|
||||
ShareID: "test-share-id",
|
||||
Author: "Test Author",
|
||||
Course: models.CourseInfo{
|
||||
ID: "test-course-id",
|
||||
Title: "Test Course",
|
||||
Description: "Test course description",
|
||||
NavigationMode: "menu",
|
||||
Lessons: []models.Lesson{
|
||||
{
|
||||
ID: "section-1",
|
||||
Title: "Test Section",
|
||||
Type: "section",
|
||||
},
|
||||
{
|
||||
ID: "lesson-1",
|
||||
Title: "Test Lesson",
|
||||
Type: "lesson",
|
||||
Items: []models.Item{
|
||||
{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "Test Heading",
|
||||
Paragraph: "Test paragraph content",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
|
||||
func BenchmarkMarkdownExporter_Export(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := NewMarkdownExporter(htmlCleaner)
|
||||
course := createTestCourseForMarkdown()
|
||||
|
||||
// Create temporary directory
|
||||
tempDir := b.TempDir()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
outputPath := filepath.Join(tempDir, "benchmark-course.md")
|
||||
_ = exporter.Export(course, outputPath)
|
||||
// Clean up for next iteration
|
||||
os.Remove(outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
|
||||
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
|
||||
htmlCleaner := services.NewHTMLCleaner()
|
||||
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
|
||||
|
||||
item := models.Item{
|
||||
Type: "text",
|
||||
Items: []models.SubItem{
|
||||
{
|
||||
Heading: "<h1>Benchmark Heading</h1>",
|
||||
Paragraph: "<p>Benchmark paragraph with <strong>bold</strong> text.</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var buf bytes.Buffer
|
||||
exporter.processTextItem(&buf, item, "###")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user