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
+11 -107
View File
@@ -6,12 +6,13 @@ import (
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
"github.com/kjanat/articulate-parser/internal/testdata"
)
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
b.ResetTimer()
for b.Loop() {
@@ -22,7 +23,7 @@ func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
func BenchmarkFactory_CreateExporter_All(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
formats := []string{"markdown", "docx", "html"}
b.ResetTimer()
@@ -36,15 +37,15 @@ func BenchmarkFactory_CreateExporter_All(b *testing.B) {
// BenchmarkAllExporters_Export benchmarks all exporters with the same course.
func BenchmarkAllExporters_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
course := createBenchmarkCourse()
course := testdata.BasicCourse()
exporters := map[string]struct {
exporter any
ext string
}{
"Markdown": {NewMarkdownExporter(htmlCleaner), ".md"},
"Docx": {NewDocxExporter(htmlCleaner), ".docx"},
"HTML": {NewHTMLExporter(htmlCleaner), ".html"},
"Markdown": {NewMarkdownExporter(htmlCleaner, nil), ".md"},
"Docx": {NewDocxExporter(htmlCleaner, nil), ".docx"},
"HTML": {NewHTMLExporter(htmlCleaner, nil), ".html"},
}
for name, exp := range exporters {
@@ -66,10 +67,10 @@ func BenchmarkAllExporters_Export(b *testing.B) {
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
func BenchmarkExporters_LargeCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
course := createLargeBenchmarkCourse()
course := testdata.LargeCourse(50)
b.Run("Markdown_Large", func(b *testing.B) {
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
tempDir := b.TempDir()
b.ResetTimer()
@@ -80,7 +81,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
})
b.Run("Docx_Large", func(b *testing.B) {
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
tempDir := b.TempDir()
b.ResetTimer()
@@ -91,7 +92,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
})
b.Run("HTML_Large", func(b *testing.B) {
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
tempDir := b.TempDir()
b.ResetTimer()
@@ -101,100 +102,3 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
}
})
}
// createBenchmarkCourse creates a standard-sized course for benchmarking.
func createBenchmarkCourse() *models.Course {
return &models.Course{
ShareID: "benchmark-id",
Author: "Benchmark Author",
Course: models.CourseInfo{
ID: "bench-course",
Title: "Benchmark Course",
Description: "Performance testing course",
NavigationMode: "menu",
Lessons: []models.Lesson{
{
ID: "lesson1",
Title: "Introduction",
Type: "lesson",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "Welcome",
Paragraph: "<p>This is a test paragraph with <strong>HTML</strong> content.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "Item 1"},
{Paragraph: "Item 2"},
{Paragraph: "Item 3"},
},
},
},
},
},
},
}
}
// createLargeBenchmarkCourse creates a large course for stress testing.
func createLargeBenchmarkCourse() *models.Course {
lessons := make([]models.Lesson, 50)
for i := range 50 {
lessons[i] = models.Lesson{
ID: string(rune(i)),
Title: "Lesson " + string(rune(i)),
Type: "lesson",
Description: "<p>This is lesson description with <em>formatting</em>.</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "Section Heading",
Paragraph: "<p>Content with <strong>bold</strong> and <em>italic</em> text.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "Point 1"},
{Paragraph: "Point 2"},
{Paragraph: "Point 3"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "Quiz Question",
Answers: []models.Answer{
{Title: "Answer A", Correct: false},
{Title: "Answer B", Correct: true},
{Title: "Answer C", Correct: false},
},
Feedback: "Good job!",
},
},
},
},
}
}
return &models.Course{
ShareID: "large-benchmark-id",
Author: "Benchmark Author",
Course: models.CourseInfo{
ID: "large-bench-course",
Title: "Large Benchmark Course",
Description: "Large performance testing course",
Lessons: lessons,
},
}
}
+7 -3
View File
@@ -29,19 +29,23 @@ const (
type DocxExporter struct {
// htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
// logger is used for logging warnings and errors
logger interfaces.Logger
}
// NewDocxExporter creates a new DocxExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion.
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
//
// Returns:
// - An implementation of the Exporter interface for DOCX format
func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
func NewDocxExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
return &DocxExporter{
htmlCleaner: htmlCleaner,
logger: logger,
}
}
@@ -91,7 +95,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
// affect the validity of the exported file.
defer func() {
if err := file.Close(); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", err)
e.logger.Warn("failed to close output file", "error", err)
}
}()
+15 -15
View File
@@ -12,7 +12,7 @@ import (
// TestNewDocxExporter tests the NewDocxExporter constructor.
func TestNewDocxExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
if exporter == nil {
t.Fatal("NewDocxExporter() returned nil")
@@ -32,7 +32,7 @@ func TestNewDocxExporter(t *testing.T) {
// TestDocxExporter_SupportedFormat tests the SupportedFormat method.
func TestDocxExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
expected := "docx"
result := exporter.SupportedFormat()
@@ -45,7 +45,7 @@ func TestDocxExporter_SupportedFormat(t *testing.T) {
// TestDocxExporter_Export tests the Export method.
func TestDocxExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create test course
testCourse := createTestCourseForDocx()
@@ -79,7 +79,7 @@ func TestDocxExporter_Export(t *testing.T) {
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx()
@@ -102,7 +102,7 @@ func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx()
@@ -118,7 +118,7 @@ func TestDocxExporter_Export_InvalidPath(t *testing.T) {
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
func TestDocxExporter_ExportLesson(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with specific lesson content
course := &models.Course{
@@ -165,7 +165,7 @@ func TestDocxExporter_ExportLesson(t *testing.T) {
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
func TestDocxExporter_ExportItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with different item types
course := &models.Course{
@@ -231,7 +231,7 @@ func TestDocxExporter_ExportItem(t *testing.T) {
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
func TestDocxExporter_ExportSubItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with sub-item containing all possible fields
course := &models.Course{
@@ -284,7 +284,7 @@ func TestDocxExporter_ExportSubItem(t *testing.T) {
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
func TestDocxExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create complex test course
course := &models.Course{
@@ -421,7 +421,7 @@ func TestDocxExporter_ComplexCourse(t *testing.T) {
// TestDocxExporter_EmptyCourse tests export of an empty course.
func TestDocxExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create minimal course
course := &models.Course{
@@ -450,7 +450,7 @@ func TestDocxExporter_EmptyCourse(t *testing.T) {
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
func TestDocxExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with HTML content that needs cleaning
course := &models.Course{
@@ -498,7 +498,7 @@ func TestDocxExporter_HTMLCleaning(t *testing.T) {
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx()
@@ -526,7 +526,7 @@ func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx()
@@ -599,7 +599,7 @@ func createTestCourseForDocx() *models.Course {
// BenchmarkDocxExporter_Export benchmarks the Export method.
func BenchmarkDocxExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
course := createTestCourseForDocx()
// Create temporary directory
@@ -618,7 +618,7 @@ func BenchmarkDocxExporter_Export(b *testing.B) {
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
exporter := NewDocxExporter(htmlCleaner, nil)
// Create complex course for benchmarking
course := &models.Course{
+35 -5
View File
@@ -13,7 +13,7 @@ import (
// ExampleNewFactory demonstrates creating an exporter factory.
func ExampleNewFactory() {
htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner)
factory := exporters.NewFactory(htmlCleaner, nil)
// Get supported formats
formats := factory.SupportedFormats()
@@ -24,7 +24,7 @@ func ExampleNewFactory() {
// ExampleFactory_CreateExporter demonstrates creating exporters.
func ExampleFactory_CreateExporter() {
htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner)
factory := exporters.NewFactory(htmlCleaner, nil)
// Create a markdown exporter
exporter, err := factory.CreateExporter("markdown")
@@ -39,7 +39,7 @@ func ExampleFactory_CreateExporter() {
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
func ExampleFactory_CreateExporter_caseInsensitive() {
htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner)
factory := exporters.NewFactory(htmlCleaner, nil)
// All these work (case-insensitive)
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
@@ -58,7 +58,7 @@ func ExampleFactory_CreateExporter_caseInsensitive() {
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
func ExampleMarkdownExporter_Export() {
htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewMarkdownExporter(htmlCleaner)
exporter := exporters.NewMarkdownExporter(htmlCleaner, nil)
course := &models.Course{
ShareID: "example-id",
@@ -81,7 +81,7 @@ func ExampleMarkdownExporter_Export() {
// ExampleDocxExporter_Export demonstrates exporting to DOCX.
func ExampleDocxExporter_Export() {
htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewDocxExporter(htmlCleaner)
exporter := exporters.NewDocxExporter(htmlCleaner, nil)
course := &models.Course{
ShareID: "example-id",
@@ -99,3 +99,33 @@ func ExampleDocxExporter_Export() {
fmt.Println("DOCX export complete")
// Output: DOCX export complete
}
// ExampleHTMLExporter_Export demonstrates exporting to HTML.
func ExampleHTMLExporter_Export() {
htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewHTMLExporter(htmlCleaner, nil)
course := &models.Course{
ShareID: "example-id",
Course: models.CourseInfo{
Title: "Example Course",
Description: "<p>A sample course</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Introduction",
Type: "lesson",
},
},
},
}
// Export to HTML file
err := exporter.Export(course, "output.html")
if err != nil {
log.Fatal(err)
}
fmt.Println("HTML export complete")
// Output: HTML export complete
}
+9 -5
View File
@@ -25,20 +25,24 @@ const (
type Factory struct {
// htmlCleaner is used by exporters to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
// logger is used by exporters for logging
logger interfaces.Logger
}
// NewFactory creates a new exporter factory.
// It takes an HTMLCleaner instance that will be passed to the exporters
// It takes an HTMLCleaner instance and a logger that will be passed to the exporters
// created by this factory.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for exporter logging
//
// Returns:
// - An implementation of the ExporterFactory interface
func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
func NewFactory(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory {
return &Factory{
htmlCleaner: htmlCleaner,
logger: logger,
}
}
@@ -47,11 +51,11 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
switch strings.ToLower(format) {
case FormatMarkdown, formatAliasMarkdown:
return NewMarkdownExporter(f.htmlCleaner), nil
return NewMarkdownExporter(f.htmlCleaner, f.logger), nil
case FormatDocx, formatAliasDocx:
return NewDocxExporter(f.htmlCleaner), nil
return NewDocxExporter(f.htmlCleaner, f.logger), nil
case FormatHTML, formatAliasHTML:
return NewHTMLExporter(f.htmlCleaner), nil
return NewHTMLExporter(f.htmlCleaner, f.logger), nil
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
+14 -14
View File
@@ -12,7 +12,7 @@ import (
// TestNewFactory tests the NewFactory constructor.
func TestNewFactory(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
if factory == nil {
t.Fatal("NewFactory() returned nil")
@@ -32,7 +32,7 @@ func TestNewFactory(t *testing.T) {
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
func TestFactory_CreateExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
testCases := []struct {
name string
@@ -135,7 +135,7 @@ func TestFactory_CreateExporter(t *testing.T) {
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
testCases := []struct {
format string
@@ -182,7 +182,7 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
testCases := []string{
"pdf",
@@ -222,7 +222,7 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
// TestFactory_SupportedFormats tests the SupportedFormats method.
func TestFactory_SupportedFormats(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
formats := factory.SupportedFormats()
@@ -255,7 +255,7 @@ func TestFactory_SupportedFormats(t *testing.T) {
// TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify.
func TestFactory_SupportedFormats_Immutable(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
// Get formats twice
formats1 := factory.SupportedFormats()
@@ -281,7 +281,7 @@ func TestFactory_SupportedFormats_Immutable(t *testing.T) {
// TestFactory_ExporterTypes tests that created exporters are of correct types.
func TestFactory_ExporterTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
// Test markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
@@ -307,7 +307,7 @@ func TestFactory_ExporterTypes(t *testing.T) {
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
// Test with markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
@@ -358,7 +358,7 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
func TestFactory_MultipleExporterCreation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
// Create multiple markdown exporters
exporter1, err := factory.CreateExporter("markdown")
@@ -395,7 +395,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
}
}()
factory := NewFactory(nil)
factory := NewFactory(nil, nil)
if factory == nil {
t.Fatal("NewFactory(nil) returned nil")
@@ -412,7 +412,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) {
// TestFactory_FormatNormalization tests that format strings are properly normalized.
func TestFactory_FormatNormalization(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
// Test formats with extra whitespace
testCases := []struct {
@@ -445,7 +445,7 @@ func TestFactory_FormatNormalization(t *testing.T) {
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
func BenchmarkFactory_CreateExporter(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
for b.Loop() {
_, _ = factory.CreateExporter("markdown")
@@ -455,7 +455,7 @@ func BenchmarkFactory_CreateExporter(b *testing.B) {
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
for b.Loop() {
_, _ = factory.CreateExporter("docx")
@@ -465,7 +465,7 @@ func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
// BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method.
func BenchmarkFactory_SupportedFormats(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
factory := NewFactory(htmlCleaner, nil)
for b.Loop() {
_ = factory.SupportedFormats()
+7 -2
View File
@@ -25,17 +25,21 @@ type HTMLExporter struct {
htmlCleaner *services.HTMLCleaner
// tmpl holds the parsed HTML template
tmpl *template.Template
// logger is used for logging warnings and errors
logger interfaces.Logger
}
// NewHTMLExporter creates a new HTMLExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed
// and a logger for logging.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
//
// Returns:
// - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
// Parse the template with custom functions
funcMap := template.FuncMap{
"safeHTML": func(s string) template.HTML {
@@ -51,6 +55,7 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &HTMLExporter{
htmlCleaner: htmlCleaner,
tmpl: tmpl,
logger: logger,
}
}
+109 -109
View File
@@ -1,175 +1,175 @@
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 10px;
margin-bottom: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 10px;
margin-bottom: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
header h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 300;
margin: 0;
font-size: 2.5rem;
font-weight: 300;
}
.course-description {
margin-top: 1rem;
font-size: 1.1rem;
opacity: 0.9;
margin-top: 1rem;
font-size: 1.1rem;
opacity: 0.9;
}
.course-info {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-info h2 {
margin-top: 0;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
margin-top: 0;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.course-info ul {
list-style: none;
padding: 0;
list-style: none;
padding: 0;
}
.course-info li {
margin: 0.5rem 0;
padding: 0.5rem;
background: #f7fafc;
border-radius: 4px;
margin: 0.5rem 0;
padding: 0.5rem;
background: #f7fafc;
border-radius: 4px;
}
.course-section {
background: #4299e1;
color: white;
padding: 1.5rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: #4299e1;
color: white;
padding: 1.5rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.course-section h2 {
margin: 0;
font-weight: 400;
margin: 0;
font-weight: 400;
}
.lesson {
background: white;
padding: 2rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid #4299e1;
background: white;
padding: 2rem;
border-radius: 8px;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid #4299e1;
}
.lesson h3 {
margin-top: 0;
color: #2d3748;
font-size: 1.5rem;
margin-top: 0;
color: #2d3748;
font-size: 1.5rem;
}
.lesson-description {
margin: 1rem 0;
padding: 1rem;
background: #f7fafc;
border-radius: 4px;
border-left: 3px solid #4299e1;
margin: 1rem 0;
padding: 1rem;
background: #f7fafc;
border-radius: 4px;
border-left: 3px solid #4299e1;
}
.item {
margin: 1.5rem 0;
padding: 1rem;
border-radius: 6px;
background: #fafafa;
border: 1px solid #e2e8f0;
margin: 1.5rem 0;
padding: 1rem;
border-radius: 6px;
background: #fafafa;
border: 1px solid #e2e8f0;
}
.item h4 {
margin-top: 0;
color: #4a5568;
font-size: 1.2rem;
text-transform: capitalize;
margin-top: 0;
color: #4a5568;
font-size: 1.2rem;
text-transform: capitalize;
}
.text-item {
background: #f0fff4;
border-left: 3px solid #48bb78;
background: #f0fff4;
border-left: 3px solid #48bb78;
}
.list-item {
background: #fffaf0;
border-left: 3px solid #ed8936;
background: #fffaf0;
border-left: 3px solid #ed8936;
}
.knowledge-check {
background: #e6fffa;
border-left: 3px solid #38b2ac;
background: #e6fffa;
border-left: 3px solid #38b2ac;
}
.multimedia-item {
background: #faf5ff;
border-left: 3px solid #9f7aea;
background: #faf5ff;
border-left: 3px solid #9f7aea;
}
.interactive-item {
background: #fff5f5;
border-left: 3px solid #f56565;
background: #fff5f5;
border-left: 3px solid #f56565;
}
.unknown-item {
background: #f7fafc;
border-left: 3px solid #a0aec0;
background: #f7fafc;
border-left: 3px solid #a0aec0;
}
.answers {
margin: 1rem 0;
margin: 1rem 0;
}
.answers h5 {
margin: 0.5rem 0;
color: #4a5568;
margin: 0.5rem 0;
color: #4a5568;
}
.answers ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.answers li {
margin: 0.3rem 0;
padding: 0.3rem;
margin: 0.3rem 0;
padding: 0.3rem;
}
.correct-answer {
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
}
.correct-answer::after {
content: " ✓";
color: #38a169;
content: " ✓";
color: #38a169;
}
.feedback {
margin: 1rem 0;
padding: 1rem;
background: #edf2f7;
border-radius: 4px;
border-left: 3px solid #4299e1;
font-style: italic;
margin: 1rem 0;
padding: 1rem;
background: #edf2f7;
border-radius: 4px;
border-left: 3px solid #4299e1;
font-style: italic;
}
.media-info {
background: #edf2f7;
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
background: #edf2f7;
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.media-info strong {
color: #4a5568;
color: #4a5568;
}
hr {
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
}
ul {
padding-left: 1.5rem;
padding-left: 1.5rem;
}
li {
margin: 0.5rem 0;
margin: 0.5rem 0;
}
+230 -9
View File
@@ -13,7 +13,7 @@ import (
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
func TestNewHTMLExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
if exporter == nil {
t.Fatal("NewHTMLExporter() returned nil")
@@ -37,7 +37,7 @@ func TestNewHTMLExporter(t *testing.T) {
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
func TestHTMLExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
expected := "html"
result := exporter.SupportedFormat()
@@ -50,7 +50,7 @@ func TestHTMLExporter_SupportedFormat(t *testing.T) {
// TestHTMLExporter_Export tests the Export method.
func TestHTMLExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
// Create test course
testCourse := createTestCourseForHTML()
@@ -129,7 +129,7 @@ func TestHTMLExporter_Export(t *testing.T) {
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
testCourse := createTestCourseForHTML()
@@ -145,7 +145,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
// TestHTMLExporter_ComplexCourse tests export of a course with complex content.
func TestHTMLExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
// Create complex test course
course := &models.Course{
@@ -276,7 +276,7 @@ func TestHTMLExporter_ComplexCourse(t *testing.T) {
// TestHTMLExporter_EmptyCourse tests export of an empty course.
func TestHTMLExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
// Create minimal course
course := &models.Course{
@@ -324,7 +324,7 @@ func TestHTMLExporter_EmptyCourse(t *testing.T) {
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
// Create course with HTML content that needs cleaning in some places
course := &models.Course{
@@ -392,6 +392,227 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) {
}
}
// TestHTMLExporter_Export_ErrorPaths tests various error conditions for the HTML exporter.
func TestHTMLExporter_Export_ErrorPaths(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
tests := []struct {
name string
course *models.Course
outputPath string
wantErr bool
errContains string
}{
{
name: "non-existent directory",
course: createTestCourseForHTML(),
outputPath: "/nonexistent/deeply/nested/path/output.html",
wantErr: true,
errContains: "failed to create file",
},
{
name: "empty output path",
course: createTestCourseForHTML(),
outputPath: "",
wantErr: true,
errContains: "failed to create file",
},
{
name: "output path is directory",
course: createTestCourseForHTML(),
outputPath: t.TempDir(), // directory, not a file
wantErr: true,
errContains: "failed to create file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := exporter.Export(tt.course, tt.outputPath)
if tt.wantErr {
if err == nil {
t.Errorf("Export() expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("Export() error = %v, want error containing %q", err, tt.errContains)
}
} else if err != nil {
t.Errorf("Export() unexpected error = %v", err)
}
})
}
}
// TestHTMLExporter_WriteHTML_ErrorPaths tests WriteHTML error conditions.
func TestHTMLExporter_WriteHTML_ErrorPaths(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
htmlExporter := exporter.(*HTMLExporter)
tests := []struct {
name string
course *models.Course
writer *failingWriter
wantErr bool
errContains string
}{
{
name: "writer fails immediately",
course: createTestCourseForHTML(),
writer: &failingWriter{failAfter: 0},
wantErr: true,
errContains: "failed to execute template",
},
{
name: "writer fails mid-write",
course: createTestCourseForHTML(),
writer: &failingWriter{failAfter: 100},
wantErr: true,
errContains: "failed to execute template",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := htmlExporter.WriteHTML(tt.writer, tt.course)
if tt.wantErr {
if err == nil {
t.Errorf("WriteHTML() expected error containing %q, got nil", tt.errContains)
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("WriteHTML() error = %v, want error containing %q", err, tt.errContains)
}
} else if err != nil {
t.Errorf("WriteHTML() unexpected error = %v", err)
}
})
}
}
// TestHTMLExporter_Export_NilCourse verifies that exporting a nil course panics.
// This documents the current behavior - nil course is not a supported input.
func TestHTMLExporter_Export_NilCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "nil-course.html")
defer func() {
if r := recover(); r == nil {
t.Error("Export(nil) expected panic, got none")
}
}()
// Export nil course - should panic
_ = exporter.Export(nil, outputPath)
}
// TestHTMLExporter_WriteHTML_NilCourse verifies that WriteHTML with nil course panics.
func TestHTMLExporter_WriteHTML_NilCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
htmlExporter := exporter.(*HTMLExporter)
defer func() {
if r := recover(); r == nil {
t.Error("WriteHTML(nil) expected panic, got none")
}
}()
var buf strings.Builder
_ = htmlExporter.WriteHTML(&buf, nil)
}
// TestHTMLExporter_WriteHTML_SuccessWithBuffer tests WriteHTML with a buffer.
func TestHTMLExporter_WriteHTML_SuccessWithBuffer(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner, nil)
htmlExporter := exporter.(*HTMLExporter)
tests := []struct {
name string
course *models.Course
contains []string
}{
{
name: "basic course",
course: createTestCourseForHTML(),
contains: []string{
"<!DOCTYPE html>",
"<html lang=\"en\">",
"Test Course",
},
},
{
name: "course with special characters",
course: &models.Course{
ShareID: "special-chars-id",
Course: models.CourseInfo{
ID: "special-course",
Title: "Course with <special> & \"characters\"",
Description: "<p>Description with &amp; entities</p>",
Lessons: []models.Lesson{},
},
},
contains: []string{
"<!DOCTYPE html>",
"Course with",
"special",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
err := htmlExporter.WriteHTML(&buf, tt.course)
if err != nil {
t.Fatalf("WriteHTML() unexpected error: %v", err)
}
output := buf.String()
for _, want := range tt.contains {
if !strings.Contains(output, want) {
t.Errorf("WriteHTML() output missing %q", want)
}
}
})
}
}
// failingWriter is a test helper that fails after writing a certain number of bytes.
type failingWriter struct {
written int
failAfter int
}
func (w *failingWriter) Write(p []byte) (n int, err error) {
if w.written >= w.failAfter {
return 0, errWriteFailed
}
w.written += len(p)
if w.written > w.failAfter {
return 0, errWriteFailed
}
return len(p), nil
}
var errWriteFailed = &writeError{msg: "simulated write failure"}
type writeError struct {
msg string
}
func (e *writeError) Error() string {
return e.msg
}
// createTestCourseForHTML creates a test course for HTML export tests.
func createTestCourseForHTML() *models.Course {
return &models.Course{
@@ -439,7 +660,7 @@ func createTestCourseForHTML() *models.Course {
// BenchmarkHTMLExporter_Export benchmarks the Export method.
func BenchmarkHTMLExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
course := createTestCourseForHTML()
tempDir := b.TempDir()
@@ -455,7 +676,7 @@ func BenchmarkHTMLExporter_Export(b *testing.B) {
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
exporter := NewHTMLExporter(htmlCleaner, nil)
// Create complex course for benchmarking
course := &models.Course{
+93 -32
View File
@@ -19,19 +19,23 @@ import (
type MarkdownExporter struct {
// htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
// logger is used for logging warnings and errors
logger interfaces.Logger
}
// NewMarkdownExporter creates a new MarkdownExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion.
// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
//
// Returns:
// - An implementation of the Exporter interface for Markdown format
func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter {
return &MarkdownExporter{
htmlCleaner: htmlCleaner,
logger: logger,
}
}
@@ -40,19 +44,22 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
var buf bytes.Buffer
// Write course header
fmt.Fprintf(&buf, "# %s\n\n", course.Course.Title)
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title))
if course.Course.Description != "" {
fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description))
desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(course.Course.Description))
if desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc)
}
}
// Add metadata
buf.WriteString("## Course Information\n\n")
fmt.Fprintf(&buf, "- **Course ID**: %s\n", course.Course.ID)
fmt.Fprintf(&buf, "- **Share ID**: %s\n", course.ShareID)
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", course.Course.NavigationMode)
fmt.Fprintf(&buf, "- **Course ID**: %s\n", strings.TrimSpace(course.Course.ID))
fmt.Fprintf(&buf, "- **Share ID**: %s\n", strings.TrimSpace(course.ShareID))
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", strings.TrimSpace(course.Course.NavigationMode))
if course.Course.ExportSettings != nil {
fmt.Fprintf(&buf, "- **Export Format**: %s\n", course.Course.ExportSettings.Format)
fmt.Fprintf(&buf, "- **Export Format**: %s\n", strings.TrimSpace(course.Course.ExportSettings.Format))
}
buf.WriteString("\n---\n\n")
@@ -60,15 +67,18 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
lessonCounter := 0
for _, lesson := range course.Course.Lessons {
if lesson.Type == lessonTypeSection {
fmt.Fprintf(&buf, "# %s\n\n", lesson.Title)
fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title))
continue
}
lessonCounter++
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, lesson.Title)
fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title))
if lesson.Description != "" {
fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description))
desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(lesson.Description))
if desc != "" {
fmt.Fprintf(&buf, "%s\n\n", desc)
}
}
// Process lesson items
@@ -76,16 +86,49 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
e.processItemToMarkdown(&buf, item, 3)
}
buf.WriteString("\n---\n\n")
buf.WriteString("---\n\n")
}
// Clean up multiple consecutive blank lines before writing
output := cleanMarkdownWhitespace(buf.Bytes())
// #nosec G306 - 0644 is appropriate for export files that should be readable by others
if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil {
if err := os.WriteFile(outputPath, output, 0o644); err != nil {
return fmt.Errorf("failed to write markdown file: %w", err)
}
return nil
}
// cleanMarkdownWhitespace normalizes whitespace in markdown output.
// It removes trailing whitespace from lines and collapses multiple blank lines into one.
func cleanMarkdownWhitespace(data []byte) []byte {
lines := strings.Split(string(data), "\n")
result := make([]string, 0, len(lines))
prevBlank := false
for _, line := range lines {
// Trim trailing whitespace from each line
trimmed := strings.TrimRight(line, " \t")
// Collapse multiple consecutive blank lines
isBlank := trimmed == ""
if isBlank && prevBlank {
continue
}
result = append(result, trimmed)
prevBlank = isBlank
}
// Remove trailing blank lines
for len(result) > 0 && result[len(result)-1] == "" {
result = result[:len(result)-1]
}
// Ensure file ends with newline
return []byte(strings.Join(result, "\n") + "\n")
}
// SupportedFormat returns "markdown".
func (e *MarkdownExporter) SupportedFormat() string {
return FormatMarkdown
@@ -123,13 +166,13 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
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)
heading := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Heading))
if heading != "" {
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
}
}
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", paragraph)
}
@@ -141,7 +184,7 @@ func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item,
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" {
fmt.Fprintf(buf, "- %s\n", paragraph)
}
@@ -166,8 +209,10 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models
e.processImageMedia(buf, subItem.Media)
}
if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
fmt.Fprintf(buf, "*%s*\n", caption)
caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption))
if caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption)
}
}
}
@@ -196,8 +241,10 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item,
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
}
if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
fmt.Fprintf(buf, "*%s*\n", caption)
caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption))
if caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption)
}
}
}
buf.WriteString("\n")
@@ -215,27 +262,35 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod
// processQuestionSubItem processes individual question items.
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title)
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
fmt.Fprintf(buf, "**Question**: %s\n\n", title)
}
}
e.processAnswers(buf, subItem.Answers)
if subItem.Feedback != "" {
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
feedback := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Feedback))
if feedback != "" {
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
}
}
}
// processAnswers processes answer choices for quiz questions.
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
if len(answers) == 0 {
return
}
buf.WriteString("**Answers**:\n")
for i, answer := range answers {
correctMark := ""
if answer.Correct {
correctMark = " ✓"
correctMark = " [correct]"
}
fmt.Fprintf(buf, "%d. %s%s\n", i+1, answer.Title, correctMark)
title := strings.TrimSpace(answer.Title)
fmt.Fprintf(buf, "%d. %s%s\n", i+1, title, correctMark)
}
}
@@ -244,8 +299,10 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
for _, subItem := range item.Items {
if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title)
fmt.Fprintf(buf, "**%s**\n\n", title)
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title)
}
}
}
}
@@ -269,11 +326,15 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite
// 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)
fmt.Fprintf(buf, "**%s**\n\n", title)
title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title)
}
}
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
fmt.Fprintf(buf, "%s\n\n", paragraph)
paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", paragraph)
}
}
}
+9 -9
View File
@@ -14,7 +14,7 @@ import (
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
func TestNewMarkdownExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
if exporter == nil {
t.Fatal("NewMarkdownExporter() returned nil")
@@ -34,7 +34,7 @@ func TestNewMarkdownExporter(t *testing.T) {
// TestMarkdownExporter_SupportedFormat tests the SupportedFormat method.
func TestMarkdownExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
expected := "markdown"
result := exporter.SupportedFormat()
@@ -47,7 +47,7 @@ func TestMarkdownExporter_SupportedFormat(t *testing.T) {
// TestMarkdownExporter_Export tests the Export method.
func TestMarkdownExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
// Create test course
testCourse := createTestCourseForMarkdown()
@@ -108,7 +108,7 @@ func TestMarkdownExporter_Export(t *testing.T) {
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
testCourse := createTestCourseForMarkdown()
@@ -283,7 +283,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "2. Paris ✓") {
if !strings.Contains(result, "2. Paris [correct]") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
@@ -432,7 +432,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
if !strings.Contains(result, "1. Answer 1") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "2. Answer 2 ✓") {
if !strings.Contains(result, "2. Answer 2 [correct]") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "3. Answer 3") {
@@ -510,7 +510,7 @@ func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
// Create complex test course
course := &models.Course{
@@ -603,7 +603,7 @@ func TestMarkdownExporter_ComplexCourse(t *testing.T) {
"- Second objective",
"### Knowledge Check",
"**Question**: What will you learn?",
"2. Everything ✓",
"2. Everything [correct]",
"**Feedback**: Great choice!",
}
@@ -654,7 +654,7 @@ func createTestCourseForMarkdown() *models.Course {
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
func BenchmarkMarkdownExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
exporter := NewMarkdownExporter(htmlCleaner, nil)
course := createTestCourseForMarkdown()
// Create temporary directory
-12
View File
@@ -1,12 +0,0 @@
# Example Course
Course description
## Course Information
- **Course ID**:
- **Share ID**: example-id
- **Navigation Mode**:
---