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
+25 -24
View File
@@ -1,30 +1,31 @@
{ {
"typescript": { "$schema": "https://dprint.dev/schemas/v0.json",
"useTabs": true,
"typescript": {},
"json": {},
"markdown": {},
"toml": {},
"dockerfile": {},
"oxc": {},
"ruff": {},
"jupyter": {},
"malva": {},
"markup": {},
"yaml": {},
"exec": {
"cwd": "${configDir}",
"commands": [
{
"command": "golangci-lint fmt --config .golangci.yml --color never --stdin {{file_path}}",
"exts": ["go"],
"cacheKeyFiles": [".golangci.yml"],
"stdin": true,
}, },
"json": {
},
"markdown": {
},
"toml": {
},
"dockerfile": {
},
"oxc": {
},
"ruff": {
},
"jupyter": {
},
"malva": {
},
"markup": {
},
"yaml": {
},
"excludes": [
"**/node_modules",
"**/*-lock.json",
], ],
},
"excludes": ["**/node_modules", "**/*-lock.json"],
"plugins": [ "plugins": [
"https://plugins.dprint.dev/typescript-0.95.13.wasm", "https://plugins.dprint.dev/typescript-0.95.13.wasm",
"https://plugins.dprint.dev/json-0.21.1.wasm", "https://plugins.dprint.dev/json-0.21.1.wasm",
+3 -1
View File
@@ -1,6 +1,7 @@
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
# golangci-lint configuration for articulate-parser # golangci-lint configuration for articulate-parser
# https://golangci-lint.run/usage/configuration/ # https://golangci-lint.run/usage/configuration/
---
version: "2" version: "2"
# Options for analysis running # Options for analysis running
@@ -51,6 +52,7 @@ formatters:
- gofmt - gofmt
- goimports - goimports
- gofumpt - gofumpt
exclusions: { paths: [".zed"] }
settings: settings:
# gofmt settings # gofmt settings
gofmt: gofmt:
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"golangci-lint": {
"command": ["golangci-lint-langserver"],
"extensions": [".go"],
"initialization": {
"command": [
"golangci-lint",
"run",
"--output.json.path",
"stdout",
"--show-stats=false",
"--issues-exit-code=1",
],
},
},
},
"formatter": {
"dprint": {
"command": ["dprint", "fmt", "$FILE"],
"extensions": [".go", ".md", ".Dockerfile", ".Dockerfile.dev", ".md"],
},
},
}
+40
View File
@@ -0,0 +1,40 @@
{
"tab_size": 2,
"hard_tabs": true,
"prettier": { "allowed": false },
"formatter": [
{ "language_server": { "name": "dprint" } }
],
"languages": {
"Go": {
"language_servers": ["gopls", "golangci-lint-langserver", "..."]
}
},
"lsp": {
"gopls": {
"enable_lsp_tasks": true,
"initialization_options": {
"workspaceFiles": ["go.mod", "go.work", "*.go"]
}
},
"golangci-lint": {
"enable_lsp_tasks": false
},
"golangci-lint-langserver": {
"binary": {
"path": "golangci-lint-langserver",
"arguments": ["-debug"]
},
"initialization_options": {
"command": [
"golangci-lint",
"run",
"--output.json.path",
"stdout",
"--show-stats=false",
"--issues-exit-code=1"
]
}
}
}
}
+11 -107
View File
@@ -6,12 +6,13 @@ import (
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services" "github.com/kjanat/articulate-parser/internal/services"
"github.com/kjanat/articulate-parser/internal/testdata"
) )
// BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation. // BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation.
func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) { func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
@@ -22,7 +23,7 @@ func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) {
// BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types. // BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types.
func BenchmarkFactory_CreateExporter_All(b *testing.B) { func BenchmarkFactory_CreateExporter_All(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
formats := []string{"markdown", "docx", "html"} formats := []string{"markdown", "docx", "html"}
b.ResetTimer() b.ResetTimer()
@@ -36,15 +37,15 @@ func BenchmarkFactory_CreateExporter_All(b *testing.B) {
// BenchmarkAllExporters_Export benchmarks all exporters with the same course. // BenchmarkAllExporters_Export benchmarks all exporters with the same course.
func BenchmarkAllExporters_Export(b *testing.B) { func BenchmarkAllExporters_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
course := createBenchmarkCourse() course := testdata.BasicCourse()
exporters := map[string]struct { exporters := map[string]struct {
exporter any exporter any
ext string ext string
}{ }{
"Markdown": {NewMarkdownExporter(htmlCleaner), ".md"}, "Markdown": {NewMarkdownExporter(htmlCleaner, nil), ".md"},
"Docx": {NewDocxExporter(htmlCleaner), ".docx"}, "Docx": {NewDocxExporter(htmlCleaner, nil), ".docx"},
"HTML": {NewHTMLExporter(htmlCleaner), ".html"}, "HTML": {NewHTMLExporter(htmlCleaner, nil), ".html"},
} }
for name, exp := range exporters { for name, exp := range exporters {
@@ -66,10 +67,10 @@ func BenchmarkAllExporters_Export(b *testing.B) {
// BenchmarkExporters_LargeCourse benchmarks exporters with large course data. // BenchmarkExporters_LargeCourse benchmarks exporters with large course data.
func BenchmarkExporters_LargeCourse(b *testing.B) { func BenchmarkExporters_LargeCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
course := createLargeBenchmarkCourse() course := testdata.LargeCourse(50)
b.Run("Markdown_Large", func(b *testing.B) { b.Run("Markdown_Large", func(b *testing.B) {
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
tempDir := b.TempDir() tempDir := b.TempDir()
b.ResetTimer() b.ResetTimer()
@@ -80,7 +81,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
}) })
b.Run("Docx_Large", func(b *testing.B) { b.Run("Docx_Large", func(b *testing.B) {
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
tempDir := b.TempDir() tempDir := b.TempDir()
b.ResetTimer() b.ResetTimer()
@@ -91,7 +92,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) {
}) })
b.Run("HTML_Large", func(b *testing.B) { b.Run("HTML_Large", func(b *testing.B) {
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
tempDir := b.TempDir() tempDir := b.TempDir()
b.ResetTimer() 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 { type DocxExporter struct {
// htmlCleaner is used to convert HTML content to plain text // htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner htmlCleaner *services.HTMLCleaner
// logger is used for logging warnings and errors
logger interfaces.Logger
} }
// NewDocxExporter creates a new DocxExporter instance. // 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: // Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data // - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for DOCX format // - 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{ return &DocxExporter{
htmlCleaner: htmlCleaner, 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. // affect the validity of the exported file.
defer func() { defer func() {
if err := file.Close(); err != nil { 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. // TestNewDocxExporter tests the NewDocxExporter constructor.
func TestNewDocxExporter(t *testing.T) { func TestNewDocxExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
if exporter == nil { if exporter == nil {
t.Fatal("NewDocxExporter() returned nil") t.Fatal("NewDocxExporter() returned nil")
@@ -32,7 +32,7 @@ func TestNewDocxExporter(t *testing.T) {
// TestDocxExporter_SupportedFormat tests the SupportedFormat method. // TestDocxExporter_SupportedFormat tests the SupportedFormat method.
func TestDocxExporter_SupportedFormat(t *testing.T) { func TestDocxExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
expected := "docx" expected := "docx"
result := exporter.SupportedFormat() result := exporter.SupportedFormat()
@@ -45,7 +45,7 @@ func TestDocxExporter_SupportedFormat(t *testing.T) {
// TestDocxExporter_Export tests the Export method. // TestDocxExporter_Export tests the Export method.
func TestDocxExporter_Export(t *testing.T) { func TestDocxExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create test course // Create test course
testCourse := createTestCourseForDocx() testCourse := createTestCourseForDocx()
@@ -79,7 +79,7 @@ func TestDocxExporter_Export(t *testing.T) {
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically. // TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) { func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx() testCourse := createTestCourseForDocx()
@@ -102,7 +102,7 @@ func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
// TestDocxExporter_Export_InvalidPath tests export with invalid output path. // TestDocxExporter_Export_InvalidPath tests export with invalid output path.
func TestDocxExporter_Export_InvalidPath(t *testing.T) { func TestDocxExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx() testCourse := createTestCourseForDocx()
@@ -118,7 +118,7 @@ func TestDocxExporter_Export_InvalidPath(t *testing.T) {
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export. // TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
func TestDocxExporter_ExportLesson(t *testing.T) { func TestDocxExporter_ExportLesson(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with specific lesson content // Create course with specific lesson content
course := &models.Course{ course := &models.Course{
@@ -165,7 +165,7 @@ func TestDocxExporter_ExportLesson(t *testing.T) {
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export. // TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
func TestDocxExporter_ExportItem(t *testing.T) { func TestDocxExporter_ExportItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with different item types // Create course with different item types
course := &models.Course{ course := &models.Course{
@@ -231,7 +231,7 @@ func TestDocxExporter_ExportItem(t *testing.T) {
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export. // TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
func TestDocxExporter_ExportSubItem(t *testing.T) { func TestDocxExporter_ExportSubItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with sub-item containing all possible fields // Create course with sub-item containing all possible fields
course := &models.Course{ course := &models.Course{
@@ -284,7 +284,7 @@ func TestDocxExporter_ExportSubItem(t *testing.T) {
// TestDocxExporter_ComplexCourse tests export of a complex course structure. // TestDocxExporter_ComplexCourse tests export of a complex course structure.
func TestDocxExporter_ComplexCourse(t *testing.T) { func TestDocxExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create complex test course // Create complex test course
course := &models.Course{ course := &models.Course{
@@ -421,7 +421,7 @@ func TestDocxExporter_ComplexCourse(t *testing.T) {
// TestDocxExporter_EmptyCourse tests export of an empty course. // TestDocxExporter_EmptyCourse tests export of an empty course.
func TestDocxExporter_EmptyCourse(t *testing.T) { func TestDocxExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create minimal course // Create minimal course
course := &models.Course{ course := &models.Course{
@@ -450,7 +450,7 @@ func TestDocxExporter_EmptyCourse(t *testing.T) {
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned. // TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
func TestDocxExporter_HTMLCleaning(t *testing.T) { func TestDocxExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create course with HTML content that needs cleaning // Create course with HTML content that needs cleaning
course := &models.Course{ course := &models.Course{
@@ -498,7 +498,7 @@ func TestDocxExporter_HTMLCleaning(t *testing.T) {
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved. // TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
func TestDocxExporter_ExistingDocxExtension(t *testing.T) { func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx() testCourse := createTestCourseForDocx()
@@ -526,7 +526,7 @@ func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive. // TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) { func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
testCourse := createTestCourseForDocx() testCourse := createTestCourseForDocx()
@@ -599,7 +599,7 @@ func createTestCourseForDocx() *models.Course {
// BenchmarkDocxExporter_Export benchmarks the Export method. // BenchmarkDocxExporter_Export benchmarks the Export method.
func BenchmarkDocxExporter_Export(b *testing.B) { func BenchmarkDocxExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
course := createTestCourseForDocx() course := createTestCourseForDocx()
// Create temporary directory // Create temporary directory
@@ -618,7 +618,7 @@ func BenchmarkDocxExporter_Export(b *testing.B) {
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course. // BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) { func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner) exporter := NewDocxExporter(htmlCleaner, nil)
// Create complex course for benchmarking // Create complex course for benchmarking
course := &models.Course{ course := &models.Course{
+35 -5
View File
@@ -13,7 +13,7 @@ import (
// ExampleNewFactory demonstrates creating an exporter factory. // ExampleNewFactory demonstrates creating an exporter factory.
func ExampleNewFactory() { func ExampleNewFactory() {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner) factory := exporters.NewFactory(htmlCleaner, nil)
// Get supported formats // Get supported formats
formats := factory.SupportedFormats() formats := factory.SupportedFormats()
@@ -24,7 +24,7 @@ func ExampleNewFactory() {
// ExampleFactory_CreateExporter demonstrates creating exporters. // ExampleFactory_CreateExporter demonstrates creating exporters.
func ExampleFactory_CreateExporter() { func ExampleFactory_CreateExporter() {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner) factory := exporters.NewFactory(htmlCleaner, nil)
// Create a markdown exporter // Create a markdown exporter
exporter, err := factory.CreateExporter("markdown") exporter, err := factory.CreateExporter("markdown")
@@ -39,7 +39,7 @@ func ExampleFactory_CreateExporter() {
// ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names. // ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names.
func ExampleFactory_CreateExporter_caseInsensitive() { func ExampleFactory_CreateExporter_caseInsensitive() {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := exporters.NewFactory(htmlCleaner) factory := exporters.NewFactory(htmlCleaner, nil)
// All these work (case-insensitive) // All these work (case-insensitive)
formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"} formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"}
@@ -58,7 +58,7 @@ func ExampleFactory_CreateExporter_caseInsensitive() {
// ExampleMarkdownExporter_Export demonstrates exporting to Markdown. // ExampleMarkdownExporter_Export demonstrates exporting to Markdown.
func ExampleMarkdownExporter_Export() { func ExampleMarkdownExporter_Export() {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewMarkdownExporter(htmlCleaner) exporter := exporters.NewMarkdownExporter(htmlCleaner, nil)
course := &models.Course{ course := &models.Course{
ShareID: "example-id", ShareID: "example-id",
@@ -81,7 +81,7 @@ func ExampleMarkdownExporter_Export() {
// ExampleDocxExporter_Export demonstrates exporting to DOCX. // ExampleDocxExporter_Export demonstrates exporting to DOCX.
func ExampleDocxExporter_Export() { func ExampleDocxExporter_Export() {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := exporters.NewDocxExporter(htmlCleaner) exporter := exporters.NewDocxExporter(htmlCleaner, nil)
course := &models.Course{ course := &models.Course{
ShareID: "example-id", ShareID: "example-id",
@@ -99,3 +99,33 @@ func ExampleDocxExporter_Export() {
fmt.Println("DOCX export complete") fmt.Println("DOCX export complete")
// Output: 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 { type Factory struct {
// htmlCleaner is used by exporters to convert HTML content to plain text // htmlCleaner is used by exporters to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner htmlCleaner *services.HTMLCleaner
// logger is used by exporters for logging
logger interfaces.Logger
} }
// NewFactory creates a new exporter factory. // 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. // created by this factory.
// //
// Parameters: // Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data // - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for exporter logging
// //
// Returns: // Returns:
// - An implementation of the ExporterFactory interface // - 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{ return &Factory{
htmlCleaner: htmlCleaner, htmlCleaner: htmlCleaner,
logger: logger,
} }
} }
@@ -47,11 +51,11 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory {
func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) { func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
switch strings.ToLower(format) { switch strings.ToLower(format) {
case FormatMarkdown, formatAliasMarkdown: case FormatMarkdown, formatAliasMarkdown:
return NewMarkdownExporter(f.htmlCleaner), nil return NewMarkdownExporter(f.htmlCleaner, f.logger), nil
case FormatDocx, formatAliasDocx: case FormatDocx, formatAliasDocx:
return NewDocxExporter(f.htmlCleaner), nil return NewDocxExporter(f.htmlCleaner, f.logger), nil
case FormatHTML, formatAliasHTML: case FormatHTML, formatAliasHTML:
return NewHTMLExporter(f.htmlCleaner), nil return NewHTMLExporter(f.htmlCleaner, f.logger), nil
default: default:
return nil, fmt.Errorf("unsupported export format: %s", format) return nil, fmt.Errorf("unsupported export format: %s", format)
} }
+14 -14
View File
@@ -12,7 +12,7 @@ import (
// TestNewFactory tests the NewFactory constructor. // TestNewFactory tests the NewFactory constructor.
func TestNewFactory(t *testing.T) { func TestNewFactory(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
if factory == nil { if factory == nil {
t.Fatal("NewFactory() returned 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. // TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
func TestFactory_CreateExporter(t *testing.T) { func TestFactory_CreateExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
testCases := []struct { testCases := []struct {
name string name string
@@ -135,7 +135,7 @@ func TestFactory_CreateExporter(t *testing.T) {
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive. // TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) { func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
testCases := []struct { testCases := []struct {
format string format string
@@ -182,7 +182,7 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats. // TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) { func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
testCases := []string{ testCases := []string{
"pdf", "pdf",
@@ -222,7 +222,7 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
// TestFactory_SupportedFormats tests the SupportedFormats method. // TestFactory_SupportedFormats tests the SupportedFormats method.
func TestFactory_SupportedFormats(t *testing.T) { func TestFactory_SupportedFormats(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
formats := factory.SupportedFormats() 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. // TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify.
func TestFactory_SupportedFormats_Immutable(t *testing.T) { func TestFactory_SupportedFormats_Immutable(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
// Get formats twice // Get formats twice
formats1 := factory.SupportedFormats() formats1 := factory.SupportedFormats()
@@ -281,7 +281,7 @@ func TestFactory_SupportedFormats_Immutable(t *testing.T) {
// TestFactory_ExporterTypes tests that created exporters are of correct types. // TestFactory_ExporterTypes tests that created exporters are of correct types.
func TestFactory_ExporterTypes(t *testing.T) { func TestFactory_ExporterTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
// Test markdown exporter // Test markdown exporter
markdownExporter, err := factory.CreateExporter("markdown") 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. // TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
func TestFactory_HTMLCleanerPropagation(t *testing.T) { func TestFactory_HTMLCleanerPropagation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
// Test with markdown exporter // Test with markdown exporter
markdownExporter, err := factory.CreateExporter("markdown") markdownExporter, err := factory.CreateExporter("markdown")
@@ -358,7 +358,7 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type. // TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
func TestFactory_MultipleExporterCreation(t *testing.T) { func TestFactory_MultipleExporterCreation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
// Create multiple markdown exporters // Create multiple markdown exporters
exporter1, err := factory.CreateExporter("markdown") 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 { if factory == nil {
t.Fatal("NewFactory(nil) returned 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. // TestFactory_FormatNormalization tests that format strings are properly normalized.
func TestFactory_FormatNormalization(t *testing.T) { func TestFactory_FormatNormalization(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
// Test formats with extra whitespace // Test formats with extra whitespace
testCases := []struct { testCases := []struct {
@@ -445,7 +445,7 @@ func TestFactory_FormatNormalization(t *testing.T) {
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method. // BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
func BenchmarkFactory_CreateExporter(b *testing.B) { func BenchmarkFactory_CreateExporter(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
for b.Loop() { for b.Loop() {
_, _ = factory.CreateExporter("markdown") _, _ = factory.CreateExporter("markdown")
@@ -455,7 +455,7 @@ func BenchmarkFactory_CreateExporter(b *testing.B) {
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters. // BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) { func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
for b.Loop() { for b.Loop() {
_, _ = factory.CreateExporter("docx") _, _ = factory.CreateExporter("docx")
@@ -465,7 +465,7 @@ func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
// BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method. // BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method.
func BenchmarkFactory_SupportedFormats(b *testing.B) { func BenchmarkFactory_SupportedFormats(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner) factory := NewFactory(htmlCleaner, nil)
for b.Loop() { for b.Loop() {
_ = factory.SupportedFormats() _ = factory.SupportedFormats()
+7 -2
View File
@@ -25,17 +25,21 @@ type HTMLExporter struct {
htmlCleaner *services.HTMLCleaner htmlCleaner *services.HTMLCleaner
// tmpl holds the parsed HTML template // tmpl holds the parsed HTML template
tmpl *template.Template tmpl *template.Template
// logger is used for logging warnings and errors
logger interfaces.Logger
} }
// NewHTMLExporter creates a new HTMLExporter instance. // 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: // Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data // - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for HTML format // - 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 // Parse the template with custom functions
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"safeHTML": func(s string) template.HTML { "safeHTML": func(s string) template.HTML {
@@ -51,6 +55,7 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &HTMLExporter{ return &HTMLExporter{
htmlCleaner: htmlCleaner, htmlCleaner: htmlCleaner,
tmpl: tmpl, tmpl: tmpl,
logger: logger,
} }
} }
+230 -9
View File
@@ -13,7 +13,7 @@ import (
// TestNewHTMLExporter tests the NewHTMLExporter constructor. // TestNewHTMLExporter tests the NewHTMLExporter constructor.
func TestNewHTMLExporter(t *testing.T) { func TestNewHTMLExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
if exporter == nil { if exporter == nil {
t.Fatal("NewHTMLExporter() returned nil") t.Fatal("NewHTMLExporter() returned nil")
@@ -37,7 +37,7 @@ func TestNewHTMLExporter(t *testing.T) {
// TestHTMLExporter_SupportedFormat tests the SupportedFormat method. // TestHTMLExporter_SupportedFormat tests the SupportedFormat method.
func TestHTMLExporter_SupportedFormat(t *testing.T) { func TestHTMLExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
expected := "html" expected := "html"
result := exporter.SupportedFormat() result := exporter.SupportedFormat()
@@ -50,7 +50,7 @@ func TestHTMLExporter_SupportedFormat(t *testing.T) {
// TestHTMLExporter_Export tests the Export method. // TestHTMLExporter_Export tests the Export method.
func TestHTMLExporter_Export(t *testing.T) { func TestHTMLExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
// Create test course // Create test course
testCourse := createTestCourseForHTML() testCourse := createTestCourseForHTML()
@@ -129,7 +129,7 @@ func TestHTMLExporter_Export(t *testing.T) {
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path. // TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
func TestHTMLExporter_Export_InvalidPath(t *testing.T) { func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
testCourse := createTestCourseForHTML() testCourse := createTestCourseForHTML()
@@ -145,7 +145,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
// TestHTMLExporter_ComplexCourse tests export of a course with complex content. // TestHTMLExporter_ComplexCourse tests export of a course with complex content.
func TestHTMLExporter_ComplexCourse(t *testing.T) { func TestHTMLExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
// Create complex test course // Create complex test course
course := &models.Course{ course := &models.Course{
@@ -276,7 +276,7 @@ func TestHTMLExporter_ComplexCourse(t *testing.T) {
// TestHTMLExporter_EmptyCourse tests export of an empty course. // TestHTMLExporter_EmptyCourse tests export of an empty course.
func TestHTMLExporter_EmptyCourse(t *testing.T) { func TestHTMLExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
// Create minimal course // Create minimal course
course := &models.Course{ course := &models.Course{
@@ -324,7 +324,7 @@ func TestHTMLExporter_EmptyCourse(t *testing.T) {
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled. // TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
func TestHTMLExporter_HTMLCleaning(t *testing.T) { func TestHTMLExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
// Create course with HTML content that needs cleaning in some places // Create course with HTML content that needs cleaning in some places
course := &models.Course{ 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. // createTestCourseForHTML creates a test course for HTML export tests.
func createTestCourseForHTML() *models.Course { func createTestCourseForHTML() *models.Course {
return &models.Course{ return &models.Course{
@@ -439,7 +660,7 @@ func createTestCourseForHTML() *models.Course {
// BenchmarkHTMLExporter_Export benchmarks the Export method. // BenchmarkHTMLExporter_Export benchmarks the Export method.
func BenchmarkHTMLExporter_Export(b *testing.B) { func BenchmarkHTMLExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
course := createTestCourseForHTML() course := createTestCourseForHTML()
tempDir := b.TempDir() tempDir := b.TempDir()
@@ -455,7 +676,7 @@ func BenchmarkHTMLExporter_Export(b *testing.B) {
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course. // BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) { func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner) exporter := NewHTMLExporter(htmlCleaner, nil)
// Create complex course for benchmarking // Create complex course for benchmarking
course := &models.Course{ course := &models.Course{
+86 -25
View File
@@ -19,19 +19,23 @@ import (
type MarkdownExporter struct { type MarkdownExporter struct {
// htmlCleaner is used to convert HTML content to plain text // htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner htmlCleaner *services.HTMLCleaner
// logger is used for logging warnings and errors
logger interfaces.Logger
} }
// NewMarkdownExporter creates a new MarkdownExporter instance. // 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: // Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data // - htmlCleaner: Service for cleaning HTML content in course data
// - logger: Logger instance for warnings and errors
// //
// Returns: // Returns:
// - An implementation of the Exporter interface for Markdown format // - 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{ return &MarkdownExporter{
htmlCleaner: htmlCleaner, htmlCleaner: htmlCleaner,
logger: logger,
} }
} }
@@ -40,19 +44,22 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
var buf bytes.Buffer var buf bytes.Buffer
// Write course header // 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 != "" { 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 // Add metadata
buf.WriteString("## Course Information\n\n") buf.WriteString("## Course Information\n\n")
fmt.Fprintf(&buf, "- **Course ID**: %s\n", course.Course.ID) fmt.Fprintf(&buf, "- **Course ID**: %s\n", strings.TrimSpace(course.Course.ID))
fmt.Fprintf(&buf, "- **Share ID**: %s\n", course.ShareID) fmt.Fprintf(&buf, "- **Share ID**: %s\n", strings.TrimSpace(course.ShareID))
fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", course.Course.NavigationMode) fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", strings.TrimSpace(course.Course.NavigationMode))
if course.Course.ExportSettings != nil { 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") buf.WriteString("\n---\n\n")
@@ -60,15 +67,18 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
lessonCounter := 0 lessonCounter := 0
for _, lesson := range course.Course.Lessons { for _, lesson := range course.Course.Lessons {
if lesson.Type == lessonTypeSection { if lesson.Type == lessonTypeSection {
fmt.Fprintf(&buf, "# %s\n\n", lesson.Title) fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title))
continue continue
} }
lessonCounter++ 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 != "" { 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 // Process lesson items
@@ -76,16 +86,49 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
e.processItemToMarkdown(&buf, item, 3) 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 // #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 fmt.Errorf("failed to write markdown file: %w", err)
} }
return nil 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". // SupportedFormat returns "markdown".
func (e *MarkdownExporter) SupportedFormat() string { func (e *MarkdownExporter) SupportedFormat() string {
return FormatMarkdown 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) { func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
for _, subItem := range item.Items { for _, subItem := range item.Items {
if subItem.Heading != "" { if subItem.Heading != "" {
heading := e.htmlCleaner.CleanHTML(subItem.Heading) heading := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Heading))
if heading != "" { if heading != "" {
fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading) fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading)
} }
} }
if subItem.Paragraph != "" { if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph) paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" { if paragraph != "" {
fmt.Fprintf(buf, "%s\n\n", 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) { func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
for _, subItem := range item.Items { for _, subItem := range item.Items {
if subItem.Paragraph != "" { if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph) paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph))
if paragraph != "" { if paragraph != "" {
fmt.Fprintf(buf, "- %s\n", paragraph) fmt.Fprintf(buf, "- %s\n", paragraph)
} }
@@ -166,10 +209,12 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models
e.processImageMedia(buf, subItem.Media) e.processImageMedia(buf, subItem.Media)
} }
if subItem.Caption != "" { if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption) caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption))
if caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption) fmt.Fprintf(buf, "*%s*\n", caption)
} }
} }
}
// processVideoMedia processes video media content. // processVideoMedia processes video media content.
func (e *MarkdownExporter) processVideoMedia(buf *bytes.Buffer, media *models.Media) { func (e *MarkdownExporter) processVideoMedia(buf *bytes.Buffer, media *models.Media) {
@@ -196,10 +241,12 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item,
fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL) fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL)
} }
if subItem.Caption != "" { if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption) caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption))
if caption != "" {
fmt.Fprintf(buf, "*%s*\n", caption) fmt.Fprintf(buf, "*%s*\n", caption)
} }
} }
}
buf.WriteString("\n") buf.WriteString("\n")
} }
@@ -215,27 +262,35 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod
// processQuestionSubItem processes individual question items. // processQuestionSubItem processes individual question items.
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) { func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" { if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title) title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
fmt.Fprintf(buf, "**Question**: %s\n\n", title) fmt.Fprintf(buf, "**Question**: %s\n\n", title)
} }
}
e.processAnswers(buf, subItem.Answers) e.processAnswers(buf, subItem.Answers)
if subItem.Feedback != "" { if subItem.Feedback != "" {
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback) feedback := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Feedback))
if feedback != "" {
fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback) fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback)
} }
} }
}
// processAnswers processes answer choices for quiz questions. // processAnswers processes answer choices for quiz questions.
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) { func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
if len(answers) == 0 {
return
}
buf.WriteString("**Answers**:\n") buf.WriteString("**Answers**:\n")
for i, answer := range answers { for i, answer := range answers {
correctMark := "" correctMark := ""
if answer.Correct { 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,11 +299,13 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models
fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix) fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix)
for _, subItem := range item.Items { for _, subItem := range item.Items {
if subItem.Title != "" { if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title) title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title) fmt.Fprintf(buf, "**%s**\n\n", title)
} }
} }
} }
}
// processDividerItem handles divider elements. // processDividerItem handles divider elements.
func (e *MarkdownExporter) processDividerItem(buf *bytes.Buffer) { func (e *MarkdownExporter) processDividerItem(buf *bytes.Buffer) {
@@ -269,11 +326,15 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite
// processGenericSubItem processes sub-items for unknown types. // processGenericSubItem processes sub-items for unknown types.
func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) { func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" { if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title) title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title))
if title != "" {
fmt.Fprintf(buf, "**%s**\n\n", title) fmt.Fprintf(buf, "**%s**\n\n", title)
} }
}
if subItem.Paragraph != "" { 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) fmt.Fprintf(buf, "%s\n\n", paragraph)
} }
} }
}
+9 -9
View File
@@ -14,7 +14,7 @@ import (
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor. // TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
func TestNewMarkdownExporter(t *testing.T) { func TestNewMarkdownExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
if exporter == nil { if exporter == nil {
t.Fatal("NewMarkdownExporter() returned nil") t.Fatal("NewMarkdownExporter() returned nil")
@@ -34,7 +34,7 @@ func TestNewMarkdownExporter(t *testing.T) {
// TestMarkdownExporter_SupportedFormat tests the SupportedFormat method. // TestMarkdownExporter_SupportedFormat tests the SupportedFormat method.
func TestMarkdownExporter_SupportedFormat(t *testing.T) { func TestMarkdownExporter_SupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
expected := "markdown" expected := "markdown"
result := exporter.SupportedFormat() result := exporter.SupportedFormat()
@@ -47,7 +47,7 @@ func TestMarkdownExporter_SupportedFormat(t *testing.T) {
// TestMarkdownExporter_Export tests the Export method. // TestMarkdownExporter_Export tests the Export method.
func TestMarkdownExporter_Export(t *testing.T) { func TestMarkdownExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
// Create test course // Create test course
testCourse := createTestCourseForMarkdown() testCourse := createTestCourseForMarkdown()
@@ -108,7 +108,7 @@ func TestMarkdownExporter_Export(t *testing.T) {
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path. // TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) { func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
testCourse := createTestCourseForMarkdown() testCourse := createTestCourseForMarkdown()
@@ -283,7 +283,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
if !strings.Contains(result, "**Answers**:") { if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading") 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") t.Error("Should mark correct answer")
} }
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") { 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") { if !strings.Contains(result, "1. Answer 1") {
t.Error("Should contain first answer") 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") t.Error("Should mark correct answer")
} }
if !strings.Contains(result, "3. Answer 3") { 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. // TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
func TestMarkdownExporter_ComplexCourse(t *testing.T) { func TestMarkdownExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
// Create complex test course // Create complex test course
course := &models.Course{ course := &models.Course{
@@ -603,7 +603,7 @@ func TestMarkdownExporter_ComplexCourse(t *testing.T) {
"- Second objective", "- Second objective",
"### Knowledge Check", "### Knowledge Check",
"**Question**: What will you learn?", "**Question**: What will you learn?",
"2. Everything ✓", "2. Everything [correct]",
"**Feedback**: Great choice!", "**Feedback**: Great choice!",
} }
@@ -654,7 +654,7 @@ func createTestCourseForMarkdown() *models.Course {
// BenchmarkMarkdownExporter_Export benchmarks the Export method. // BenchmarkMarkdownExporter_Export benchmarks the Export method.
func BenchmarkMarkdownExporter_Export(b *testing.B) { func BenchmarkMarkdownExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner) exporter := NewMarkdownExporter(htmlCleaner, nil)
course := createTestCourseForMarkdown() course := createTestCourseForMarkdown()
// Create temporary directory // Create temporary directory
-12
View File
@@ -1,12 +0,0 @@
# Example Course
Course description
## Course Information
- **Course ID**:
- **Share ID**: example-id
- **Navigation Mode**:
---
+45
View File
@@ -2,6 +2,8 @@
// These structures closely match the JSON format used by Articulate Rise. // These structures closely match the JSON format used by Articulate Rise.
package models package models
import "fmt"
// Course represents the top-level structure of an Articulate Rise course. // Course represents the top-level structure of an Articulate Rise course.
// It contains metadata and the actual course content. // It contains metadata and the actual course content.
type Course struct { type Course struct {
@@ -53,3 +55,46 @@ type LabelSet struct {
// Labels is a mapping of label keys to their customized values // Labels is a mapping of label keys to their customized values
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
} }
// Validate checks whether the Course structure contains valid data.
// It returns an error describing the first validation failure found,
// or nil if the course is valid.
//
// Validation rules:
// - ShareID must not be empty
// - Course.ID must not be empty
// - Course.Title must not be empty
// - Each Lesson must have a non-empty ID and Title
func (c *Course) Validate() error {
if c.ShareID == "" {
return &ValidationError{Field: "ShareID", Message: "share ID is required"}
}
if c.Course.ID == "" {
return &ValidationError{Field: "Course.ID", Message: "course ID is required"}
}
if c.Course.Title == "" {
return &ValidationError{Field: "Course.Title", Message: "course title is required"}
}
for i, lesson := range c.Course.Lessons {
if lesson.ID == "" {
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty ID", i)}
}
if lesson.Title == "" {
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty title", i)}
}
}
return nil
}
// ValidationError represents a validation failure for a specific field.
type ValidationError struct {
// Field is the name of the field that failed validation
Field string
// Message describes the validation failure
Message string
}
// Error implements the error interface for ValidationError.
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
+156
View File
@@ -2,6 +2,7 @@ package models
import ( import (
"encoding/json" "encoding/json"
"errors"
"reflect" "reflect"
"testing" "testing"
) )
@@ -664,6 +665,161 @@ func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
} }
} }
// TestCourse_Validate tests the Course.Validate method.
func TestCourse_Validate(t *testing.T) {
tests := []struct {
name string
course Course
wantErr bool
errField string
errSubstr string
}{
{
name: "valid course",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "lesson-1", Title: "First Lesson"},
},
},
},
wantErr: false,
},
{
name: "valid course with no lessons",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{},
},
},
wantErr: false,
},
{
name: "missing share ID",
course: Course{
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
},
},
wantErr: true,
errField: "ShareID",
errSubstr: "share ID is required",
},
{
name: "missing course ID",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
Title: "Test Course",
},
},
wantErr: true,
errField: "Course.ID",
errSubstr: "course ID is required",
},
{
name: "missing course title",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
},
},
wantErr: true,
errField: "Course.Title",
errSubstr: "course title is required",
},
{
name: "lesson with empty ID",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "", Title: "First Lesson"},
},
},
},
wantErr: true,
errField: "Course.Lessons",
errSubstr: "lesson at index 0 has empty ID",
},
{
name: "lesson with empty title",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "lesson-1", Title: ""},
},
},
},
wantErr: true,
errField: "Course.Lessons",
errSubstr: "lesson at index 0 has empty title",
},
{
name: "second lesson invalid",
course: Course{
ShareID: "test-share-id",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Lessons: []Lesson{
{ID: "lesson-1", Title: "First Lesson"},
{ID: "lesson-2", Title: ""},
},
},
},
wantErr: true,
errField: "Course.Lessons",
errSubstr: "lesson at index 1 has empty title",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.course.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Course.Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
var valErr *ValidationError
if ok := errors.As(err, &valErr); !ok {
t.Errorf("Expected ValidationError, got %T", err)
return
}
if valErr.Field != tt.errField {
t.Errorf("ValidationError.Field = %q, want %q", valErr.Field, tt.errField)
}
if valErr.Message != tt.errSubstr {
t.Errorf("ValidationError.Message = %q, want %q", valErr.Message, tt.errSubstr)
}
}
})
}
}
// TestValidationError_Error tests the ValidationError.Error method.
func TestValidationError_Error(t *testing.T) {
err := &ValidationError{Field: "TestField", Message: "test message"}
expected := "validation error on TestField: test message"
if err.Error() != expected {
t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected)
}
}
// compareMaps compares two any values that should be maps. // compareMaps compares two any values that should be maps.
func compareMaps(original, unmarshaled any) bool { func compareMaps(original, unmarshaled any) bool {
origMap, origOk := original.(map[string]any) origMap, origOk := original.(map[string]any)
+22 -9
View File
@@ -31,33 +31,46 @@ func NewApp(parser interfaces.CourseParser, exporterFactory interfaces.ExporterF
} }
// ProcessCourseFromFile loads a course from a local file and exports it to the specified format. // ProcessCourseFromFile loads a course from a local file and exports it to the specified format.
// It takes the path to the course file, the desired export format, and the output file path. // It takes a context for cancellation, the path to the course file, the desired export format,
// Returns an error if loading or exporting fails. // and the output file path. Returns an error if loading or exporting fails, or if the context
func (a *App) ProcessCourseFromFile(filePath, format, outputPath string) error { // is canceled.
func (a *App) ProcessCourseFromFile(ctx context.Context, filePath, format, outputPath string) error {
// Check for cancellation before loading
if err := ctx.Err(); err != nil {
return fmt.Errorf("operation canceled: %w", err)
}
course, err := a.parser.LoadCourseFromFile(filePath) course, err := a.parser.LoadCourseFromFile(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to load course from file: %w", err) return fmt.Errorf("failed to load course from file: %w", err)
} }
return a.exportCourse(course, format, outputPath) return a.exportCourse(ctx, course, format, outputPath)
} }
// ProcessCourseFromURI fetches a course from the provided URI and exports it to the specified format. // ProcessCourseFromURI fetches a course from the provided URI and exports it to the specified format.
// It takes the URI to fetch the course from, the desired export format, and the output file path. // It takes a context for cancellation, the URI to fetch the course from, the desired export format,
// Returns an error if fetching or exporting fails. // and the output file path. Returns an error if fetching or exporting fails, or if the context
// is canceled.
func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath string) error { func (a *App) ProcessCourseFromURI(ctx context.Context, uri, format, outputPath string) error {
course, err := a.parser.FetchCourse(ctx, uri) course, err := a.parser.FetchCourse(ctx, uri)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch course: %w", err) return fmt.Errorf("failed to fetch course: %w", err)
} }
return a.exportCourse(course, format, outputPath) return a.exportCourse(ctx, course, format, outputPath)
} }
// exportCourse exports a course to the specified format and output path. // exportCourse exports a course to the specified format and output path.
// It's a helper method that creates the appropriate exporter and performs the export. // It's a helper method that creates the appropriate exporter and performs the export.
// Returns an error if creating the exporter or exporting the course fails. // Returns an error if creating the exporter or exporting the course fails, or if the
func (a *App) exportCourse(course *models.Course, format, outputPath string) error { // context is canceled.
func (a *App) exportCourse(ctx context.Context, course *models.Course, format, outputPath string) error {
// Check for cancellation before export
if err := ctx.Err(); err != nil {
return fmt.Errorf("operation canceled: %w", err)
}
exporter, err := a.exporterFactory.CreateExporter(format) exporter, err := a.exporterFactory.CreateExporter(format)
if err != nil { if err != nil {
return fmt.Errorf("failed to create exporter: %w", err) return fmt.Errorf("failed to create exporter: %w", err)
+1 -1
View File
@@ -207,7 +207,7 @@ func TestApp_ProcessCourseFromFile(t *testing.T) {
tt.setupMocks(parser, factory, exporter) tt.setupMocks(parser, factory, exporter)
app := NewApp(parser, factory) app := NewApp(parser, factory)
err := app.ProcessCourseFromFile(tt.filePath, tt.format, tt.outputPath) err := app.ProcessCourseFromFile(context.Background(), tt.filePath, tt.format, tt.outputPath)
if tt.expectedError != "" { if tt.expectedError != "" {
if err == nil { if err == nil {
+60 -9
View File
@@ -5,7 +5,10 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"log/slog"
"time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/services" "github.com/kjanat/articulate-parser/internal/services"
) )
@@ -14,8 +17,8 @@ func ExampleNewArticulateParser() {
// Create a no-op logger for this example // Create a no-op logger for this example
logger := services.NewNoOpLogger() logger := services.NewNoOpLogger()
// Create parser with defaults // Create parser with defaults (nil config uses defaults)
parser := services.NewArticulateParser(logger, "", 0) parser := services.NewArticulateParser(logger, nil)
fmt.Printf("Parser created: %T\n", parser) fmt.Printf("Parser created: %T\n", parser)
// Output: Parser created: *services.ArticulateParser // Output: Parser created: *services.ArticulateParser
@@ -26,11 +29,11 @@ func ExampleNewArticulateParser_custom() {
logger := services.NewNoOpLogger() logger := services.NewNoOpLogger()
// Create parser with custom base URL and timeout // Create parser with custom base URL and timeout
parser := services.NewArticulateParser( cfg := &config.Config{
logger, BaseURL: "https://custom.articulate.com",
"https://custom.articulate.com", RequestTimeout: 60 * time.Second,
60_000_000_000, // 60 seconds in nanoseconds }
) parser := services.NewArticulateParser(logger, cfg)
fmt.Printf("Parser configured: %T\n", parser) fmt.Printf("Parser configured: %T\n", parser)
// Output: Parser configured: *services.ArticulateParser // Output: Parser configured: *services.ArticulateParser
@@ -39,7 +42,7 @@ func ExampleNewArticulateParser_custom() {
// ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file. // ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file.
func ExampleArticulateParser_LoadCourseFromFile() { func ExampleArticulateParser_LoadCourseFromFile() {
logger := services.NewNoOpLogger() logger := services.NewNoOpLogger()
parser := services.NewArticulateParser(logger, "", 0) parser := services.NewArticulateParser(logger, nil)
// In a real scenario, you'd have an actual file // In a real scenario, you'd have an actual file
// This example shows the API usage // This example shows the API usage
@@ -52,7 +55,7 @@ func ExampleArticulateParser_LoadCourseFromFile() {
// ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI. // ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI.
func ExampleArticulateParser_FetchCourse() { func ExampleArticulateParser_FetchCourse() {
logger := services.NewNoOpLogger() logger := services.NewNoOpLogger()
parser := services.NewArticulateParser(logger, "", 0) parser := services.NewArticulateParser(logger, nil)
// Create a context with timeout // Create a context with timeout
ctx := context.Background() ctx := context.Background()
@@ -64,6 +67,54 @@ func ExampleArticulateParser_FetchCourse() {
} }
} }
// ExampleNewSlogLogger demonstrates creating a JSON logger.
func ExampleNewSlogLogger() {
// Create a JSON structured logger at Info level
// Output goes to stdout in JSON format
logger := services.NewSlogLogger(slog.LevelInfo)
// Logger is ready to use - calls like logger.Info("msg") output JSON
fmt.Printf("Logger type: %T\n", logger)
// Output: Logger type: *services.SlogLogger
}
// ExampleNewTextLogger demonstrates creating a human-readable text logger.
func ExampleNewTextLogger() {
// Create a text logger, more readable for development
logger := services.NewTextLogger(slog.LevelDebug)
// Logger is ready - calls like logger.Debug("msg") output text
fmt.Printf("Logger type: %T\n", logger)
// Output: Logger type: *services.SlogLogger
}
// ExampleNewNoOpLogger demonstrates creating a no-op logger for testing.
func ExampleNewNoOpLogger() {
logger := services.NewNoOpLogger()
// All log calls are silently discarded
logger.Debug("this won't appear")
logger.Info("neither will this")
logger.Error("or this")
fmt.Println("Logging silently completed")
// Output: Logging silently completed
}
// ExampleSlogLogger_With demonstrates creating a child logger with context.
func ExampleSlogLogger_With() {
logger := services.NewNoOpLogger()
// Create a child logger with additional context
requestLogger := logger.With("request_id", "abc123", "user", "john")
// All logs from requestLogger will include request_id and user
requestLogger.Info("processing request")
fmt.Printf("Child logger type: %T\n", requestLogger)
// Output: Child logger type: *services.NoOpLogger
}
// ExampleHTMLCleaner demonstrates cleaning HTML content. // ExampleHTMLCleaner demonstrates cleaning HTML content.
func ExampleHTMLCleaner() { func ExampleHTMLCleaner() {
cleaner := services.NewHTMLCleaner() cleaner := services.NewHTMLCleaner()
+15
View File
@@ -0,0 +1,15 @@
package services
import (
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/interfaces"
)
// NewLoggerFromConfig creates a logger based on the configuration.
// Returns a JSON logger if cfg.LogFormat is "json", otherwise returns a text logger.
func NewLoggerFromConfig(cfg *config.Config) interfaces.Logger {
if cfg.LogFormat == "json" {
return NewSlogLogger(cfg.LogLevel)
}
return NewTextLogger(cfg.LogLevel)
}
+10 -17
View File
@@ -9,17 +9,14 @@ import (
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
"time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
) )
// Default endpoint configuration for the Articulate Rise API. // riseHost is the only host accepted in Articulate Rise share URIs.
const ( const riseHost = "rise.articulate.com"
riseHost = "rise.articulate.com"
defaultBaseURL = "https://" + riseHost
)
// shareIDRegex is compiled once at package init for extracting share IDs from URIs. // shareIDRegex is compiled once at package init for extracting share IDs from URIs.
var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`) var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
@@ -35,23 +32,19 @@ type ArticulateParser struct {
Logger interfaces.Logger Logger interfaces.Logger
} }
// NewArticulateParser creates a new ArticulateParser instance. // NewArticulateParser creates a new ArticulateParser instance using the provided configuration.
// If baseURL is empty, uses the default Articulate Rise API URL. // If cfg is nil, a default configuration will be used.
// If timeout is zero, uses a 30-second timeout. func NewArticulateParser(logger interfaces.Logger, cfg *config.Config) interfaces.CourseParser {
func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser {
if logger == nil { if logger == nil {
logger = NewNoOpLogger() logger = NewNoOpLogger()
} }
if baseURL == "" { if cfg == nil {
baseURL = defaultBaseURL cfg = config.Load()
}
if timeout == 0 {
timeout = 30 * time.Second
} }
return &ArticulateParser{ return &ArticulateParser{
BaseURL: baseURL, BaseURL: cfg.BaseURL,
Client: &http.Client{ Client: &http.Client{
Timeout: timeout, Timeout: cfg.RequestTimeout,
}, },
Logger: logger, Logger: logger,
} }
+12 -2
View File
@@ -8,7 +8,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
) )
@@ -133,7 +135,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile(b *testing.B) {
b.Fatalf("Failed to write file: %v", err) b.Fatalf("Failed to write file: %v", err)
} }
parser := NewArticulateParser(nil, "", 0) cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
@@ -180,7 +186,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile_Large(b *testing.B) {
b.Fatalf("Failed to write file: %v", err) b.Fatalf("Failed to write file: %v", err)
} }
parser := NewArticulateParser(nil, "", 0) cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
b.ResetTimer() b.ResetTimer()
for b.Loop() { for b.Loop() {
+207
View File
@@ -0,0 +1,207 @@
package services
import (
"encoding/json"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
)
// FuzzArticulateParser_ParseJSON fuzzes JSON parsing to ensure no panics on malformed input.
// It tests that the parser gracefully handles any byte sequence without crashing.
func FuzzArticulateParser_ParseJSON(f *testing.F) {
// Add seed corpus with valid JSON structures
f.Add([]byte(`{"shareId":"test","course":{"id":"1","title":"Test"}}`))
f.Add([]byte(`{"shareId":"abc123","author":"John Doe","course":{"id":"c1","title":"Course Title","description":"A test course"}}`))
f.Add([]byte(`{}`))
f.Add([]byte(`{"course":{}}`))
f.Add([]byte(`{"shareId":"","course":{"id":"","title":"","lessons":[]}}`))
// Add seed corpus with invalid/edge case inputs
f.Add([]byte(`invalid`))
f.Add([]byte(``))
f.Add([]byte(`null`))
f.Add([]byte(`[]`))
f.Add([]byte(`"string"`))
f.Add([]byte(`123`))
f.Add([]byte(`true`))
f.Add([]byte(`{`))
f.Add([]byte(`}`))
f.Add([]byte(`{"unclosed": `))
f.Add([]byte(`{"key": "value with \x00 null byte"}`))
// Add seed corpus with nested structures
f.Add([]byte(`{"course":{"lessons":[{"id":"l1","title":"Lesson 1","items":[{"id":"i1","type":"text","data":"content"}]}]}}`))
f.Add([]byte(`{"course":{"lessons":[{},{},{}]}}`))
// Add seed corpus with special characters
f.Add([]byte(`{"shareId":"test<script>alert('xss')</script>"}`))
f.Add([]byte(`{"course":{"title":"Test\n\r\t\u0000Title"}}`))
f.Add([]byte(`{"course":{"description":"<p>HTML content</p>"}}`))
f.Fuzz(func(t *testing.T, data []byte) {
// Test that unmarshaling doesn't panic
var course models.Course
_ = json.Unmarshal(data, &course)
// Also test that re-marshaling doesn't panic
_, _ = json.Marshal(&course)
// Test accessing fields doesn't panic
_ = course.ShareID
_ = course.Author
_ = course.Course.ID
_ = course.Course.Title
_ = course.Course.Description
// Test iterating over lessons doesn't panic
for _, lesson := range course.Course.Lessons {
_ = lesson.ID
_ = lesson.Title
for _, item := range lesson.Items {
_ = item.ID
_ = item.Type
}
}
})
}
// FuzzArticulateParser_LoadCourseFromFile_JSON tests JSON parsing behavior used by LoadCourseFromFile.
// This fuzz test ensures the JSON unmarshaling logic handles arbitrary input gracefully.
func FuzzArticulateParser_LoadCourseFromFile_JSON(f *testing.F) {
// Seed corpus mimicking file contents
f.Add([]byte(`{"shareId":"file-test","course":{"id":"1","title":"From File"}}`))
f.Add([]byte(`{
"shareId": "multiline",
"course": {
"id": "2",
"title": "Multiline JSON"
}
}`))
f.Add([]byte("\xef\xbb\xbf{\"shareId\":\"bom\"}")) // UTF-8 BOM prefix
f.Add([]byte(`{"shareId": "spaces", "course": {}}`))
f.Fuzz(func(t *testing.T, data []byte) {
var course models.Course
err := json.Unmarshal(data, &course)
// If unmarshaling succeeded, verify the result is usable
if err == nil {
// Access all fields to ensure no panics on zero values
_ = course.ShareID
_ = course.Author
_ = course.Course.ID
_ = course.Course.Title
// Iterate through nested structures
for _, lesson := range course.Course.Lessons {
_ = lesson.ID
_ = lesson.Title
_ = lesson.Type
for _, item := range lesson.Items {
_ = item.ID
_ = item.Type
_ = item.Data
}
}
}
})
}
// FuzzHTMLCleaner_CleanHTML fuzzes the HTML cleaner to ensure no panics on malformed HTML.
// It tests that arbitrary HTML-like input is handled gracefully.
func FuzzHTMLCleaner_CleanHTML(f *testing.F) {
cleaner := NewHTMLCleaner()
// Add seed corpus with valid HTML patterns
f.Add("<p>Simple paragraph</p>")
f.Add("<div><p>Nested</p></div>")
f.Add("<h1>Title</h1><p>Content</p>")
f.Add("<strong>Bold</strong> and <em>italic</em>")
f.Add("<a href=\"https://example.com\">Link</a>")
f.Add("<img src=\"image.png\" alt=\"Image\"/>")
f.Add("<br/><hr/>")
f.Add("<ul><li>Item 1</li><li>Item 2</li></ul>")
f.Add("<table><tr><td>Cell</td></tr></table>")
// Add seed corpus with HTML entities
f.Add("AT&amp;T")
f.Add("&lt;script&gt;alert('xss')&lt;/script&gt;")
f.Add("&nbsp;&nbsp;&nbsp;")
f.Add("&#39;apostrophe&#39;")
f.Add("&quot;quoted&quot;")
f.Add("&eacute;&iuml;&ouml;")
f.Add("&#x27;hex entity&#x27;")
f.Add("&#8217;numeric entity&#8217;")
// Add seed corpus with malformed/edge case HTML
f.Add("")
f.Add("plain text no HTML")
f.Add("<p>Unclosed paragraph")
f.Add("</p>Close tag first<p>")
f.Add("<div><div><div>Deep nesting")
f.Add("<script>alert('xss')</script>")
f.Add("<style>body{color:red}</style>")
f.Add("<!--comment-->")
f.Add("<!DOCTYPE html>")
f.Add("<![CDATA[data]]>")
f.Add("<p class=\"test\" id='test' data-attr=unquoted>Attributes</p>")
f.Add("<p\n\t>Whitespace in tag</p\n>")
f.Add("<>empty tag</>")
f.Add("<<<nested angles>>>")
f.Add("<p onclick=\"evil()\">Event handler</p>")
// Add seed corpus with special characters
f.Add("<p>\x00null byte</p>")
f.Add("<p>\xffhigh byte</p>")
f.Add("<p>emoji: \xf0\x9f\x98\x80</p>")
f.Add("<p>unicode: \u2603</p>")
f.Add("<p>newlines\n\r\nand\rtabs\there</p>")
// Add seed corpus with potentially problematic patterns
f.Add(string(make([]byte, 0))) // Empty
f.Add(string(make([]byte, 1))) // Single null byte
f.Add("<" + string(make([]byte, 100)) + ">") // Tag with null bytes
f.Add("<p>" + string(make([]byte, 1000)) + "</p>") // Large content
f.Fuzz(func(t *testing.T, input string) {
// Test that CleanHTML doesn't panic on any input
result := cleaner.CleanHTML(input)
// Verify the result is a valid string (doesn't panic on access)
_ = len(result)
// Test that the result can be used in common string operations without panic
_ = result == ""
_ = result == input
// Test that calling CleanHTML on its own output doesn't panic (idempotency check)
_ = cleaner.CleanHTML(result)
})
}
// FuzzHTMLCleaner_CleanHTML_Bytes tests HTML cleaner with raw byte sequences.
// This catches issues with invalid UTF-8 and binary data in HTML content.
func FuzzHTMLCleaner_CleanHTML_Bytes(f *testing.F) {
cleaner := NewHTMLCleaner()
// Add seed corpus with byte sequences
f.Add([]byte("<p>normal</p>"))
f.Add([]byte{0x3c, 0x70, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x70, 0x3e}) // <p>..invalid..</p>
f.Add([]byte{0x00}) // Null byte
f.Add([]byte{0xff, 0xfe}) // Invalid UTF-8
f.Add([]byte{0xef, 0xbb, 0xbf, 0x3c, 0x70, 0x3e, 0x3c, 0x2f, 0x70, 0x3e}) // UTF-8 BOM + <p></p>
f.Add([]byte("<p>\xc0\xaf</p>")) // Overlong UTF-8
f.Fuzz(func(t *testing.T, data []byte) {
// Convert bytes to string and test
input := string(data)
// Test that CleanHTML doesn't panic
result := cleaner.CleanHTML(input)
// Verify result is usable
_ = len(result)
_ = []byte(result)
})
}
+13 -3
View File
@@ -11,12 +11,14 @@ import (
"testing" "testing"
"time" "time"
"github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/models"
) )
// TestNewArticulateParser tests the NewArticulateParser constructor. // TestNewArticulateParser tests the NewArticulateParser constructor.
func TestNewArticulateParser(t *testing.T) { func TestNewArticulateParser(t *testing.T) {
parser := NewArticulateParser(nil, "", 0) // Test with nil config (should use defaults)
parser := NewArticulateParser(nil, nil)
if parser == nil { if parser == nil {
t.Fatal("NewArticulateParser() returned nil") t.Fatal("NewArticulateParser() returned nil")
@@ -212,7 +214,11 @@ func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
t.Fatalf("Failed to write test file: %v", err) t.Fatalf("Failed to write test file: %v", err)
} }
parser := NewArticulateParser(nil, "", 0) cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
tests := []struct { tests := []struct {
name string name string
@@ -271,7 +277,11 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
t.Fatalf("Failed to write test file: %v", err) t.Fatalf("Failed to write test file: %v", err)
} }
parser := NewArticulateParser(nil, "", 0) cfg := &config.Config{
BaseURL: "https://rise.articulate.com",
RequestTimeout: 30 * time.Second,
}
parser := NewArticulateParser(nil, cfg)
_, err := parser.LoadCourseFromFile(tempFile) _, err := parser.LoadCourseFromFile(tempFile)
if err == nil { if err == nil {
+467
View File
@@ -0,0 +1,467 @@
// Package testdata provides shared test fixtures for Articulate Rise course testing.
// It contains builders for creating Course, Lesson, and Item structures with various
// configurations to support unit tests, integration tests, and benchmarks.
package testdata
import (
"fmt"
"github.com/kjanat/articulate-parser/internal/models"
)
// SampleAnswers provides a reusable set of quiz answers for testing.
var SampleAnswers = []models.Answer{
{ID: "ans-1", Title: "Option A", Correct: false},
{ID: "ans-2", Title: "Option B", Correct: true},
{ID: "ans-3", Title: "Option C", Correct: false},
{ID: "ans-4", Title: "Option D", Correct: false},
}
// SampleSubItems provides a reusable set of sub-items for testing.
var SampleSubItems = []models.SubItem{
{
ID: "sub-1",
Heading: "Sample Heading",
Paragraph: "<p>Sample paragraph with <strong>formatting</strong>.</p>",
},
{
ID: "sub-2",
Paragraph: "<p>Another paragraph with <em>emphasis</em>.</p>",
},
}
// MinimalCourse returns a course with just ShareID and Title set.
// Useful for testing edge cases with minimal data.
func MinimalCourse() *models.Course {
return &models.Course{
ShareID: "minimal-share-id",
Course: models.CourseInfo{
Title: "Minimal Course",
},
}
}
// BasicCourse returns a course with 1 section, 1 lesson, and 1 text item.
// Suitable for basic functionality tests.
func BasicCourse() *models.Course {
return &models.Course{
ShareID: "basic-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "basic-course-id",
Title: "Basic Test Course",
Description: "<p>A basic course for testing.</p>",
NavigationMode: "menu",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Introduction Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "First Lesson",
Type: "lesson",
Description: "<p>Lesson introduction text.</p>",
Items: []models.Item{
TextItem("Welcome", "<p>Welcome to the course!</p>"),
},
},
},
},
}
}
// CompleteCourse returns a course with all item types represented.
// Useful for comprehensive export and parsing tests.
func CompleteCourse() *models.Course {
return &models.Course{
ShareID: "complete-share-id",
Author: "Complete Test Author",
Course: models.CourseInfo{
ID: "complete-course-id",
Title: "Complete Test Course",
Description: "<p>A comprehensive course with <strong>all</strong> item types.</p>",
Color: "#3366CC",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Title: "Exported Complete Course",
Format: "scorm",
},
CoverImage: &models.Media{
Image: &models.ImageMedia{
Key: "cover-img-key",
Type: "jpg",
Width: 1920,
Height: 1080,
OriginalURL: "https://example.com/cover.jpg",
},
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Overview",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction",
Type: "lesson",
Description: "<p>Introduction to the course content.</p>",
Icon: "book",
Items: []models.Item{
TextItem("Welcome", "<p>Welcome to this comprehensive course!</p>"),
TextItem("Objectives", "<p>In this course you will learn:</p>"),
ListItem("First objective", "Second objective", "Third objective"),
DividerItem(),
MediaItem("video", "https://example.com/intro.mp4"),
MediaItem("image", "https://example.com/diagram.png"),
},
},
{
ID: "lesson-2",
Title: "Core Concepts",
Type: "lesson",
Description: "<p>Core concepts and fundamentals.</p>",
Items: []models.Item{
TextItem("Key Concepts", "<p>Let's explore the key concepts.</p>"),
KnowledgeCheckItem("What is the primary purpose of this course?", []models.Answer{
{ID: "kc-1-a", Title: "Learning basics", Correct: false},
{ID: "kc-1-b", Title: "Comprehensive understanding", Correct: true},
{ID: "kc-1-c", Title: "Quick overview", Correct: false},
}),
InteractiveItem("Interactive Exercise"),
},
},
{
ID: "lesson-3",
Title: "Advanced Topics",
Type: "lesson",
Description: "<p>Deep dive into advanced topics.</p>",
Items: []models.Item{
TextItem("Advanced Section", "<p>This section covers advanced material.</p>"),
FlipCardItem("Front content", "Back content"),
QuoteItem("Learning is not attained by chance.", "Abigail Adams"),
},
},
{
ID: "section-2",
Title: "Summary",
Type: "section",
},
{
ID: "lesson-4",
Title: "Conclusion",
Type: "lesson",
Description: "<p>Course wrap-up and next steps.</p>",
Items: []models.Item{
TextItem("Summary", "<p>Thank you for completing this course!</p>"),
ListItem("Review materials regularly", "Practice learned skills", "Share knowledge"),
},
},
},
},
LabelSet: models.LabelSet{
ID: "default-labels",
Name: "Default",
Labels: map[string]string{
"next": "Next",
"previous": "Previous",
"complete": "Mark Complete",
},
},
}
}
// LargeCourse returns a parameterized course for benchmarking with the specified number of lessons.
// Each lesson contains multiple item types for realistic performance testing.
func LargeCourse(lessons int) *models.Course {
courseLessons := make([]models.Lesson, 0, lessons+lessons/5) // +sections
for i := range lessons {
// Add section header every 5 lessons
if i%5 == 0 {
courseLessons = append(courseLessons, models.Lesson{
ID: fmt.Sprintf("section-%d", i/5+1),
Title: fmt.Sprintf("Section %d", i/5+1),
Type: "section",
})
}
lesson := models.Lesson{
ID: fmt.Sprintf("lesson-%d", i+1),
Title: fmt.Sprintf("Lesson %d", i+1),
Type: "lesson",
Description: fmt.Sprintf("<p>Description for lesson %d with <em>formatting</em>.</p>", i+1),
Items: []models.Item{
TextItem(
fmt.Sprintf("Heading %d", i+1),
fmt.Sprintf("<p>Content for lesson %d with <strong>bold</strong> and <em>italic</em> text.</p>", i+1),
),
ListItem("Point 1", "Point 2", "Point 3"),
KnowledgeCheckItem(
fmt.Sprintf("Quiz Question %d", i+1),
[]models.Answer{
{ID: fmt.Sprintf("q%d-a", i), Title: "Answer A", Correct: false},
{ID: fmt.Sprintf("q%d-b", i), Title: "Answer B", Correct: true},
{ID: fmt.Sprintf("q%d-c", i), Title: "Answer C", Correct: false},
},
),
},
}
courseLessons = append(courseLessons, lesson)
}
return &models.Course{
ShareID: "large-benchmark-id",
Author: "Benchmark Author",
Course: models.CourseInfo{
ID: "large-benchmark-course",
Title: fmt.Sprintf("Large Benchmark Course (%d lessons)", lessons),
Description: "<p>Large course for performance testing.</p>",
NavigationMode: "menu",
Lessons: courseLessons,
},
}
}
// TextItem creates a text item with the specified heading and paragraph content.
func TextItem(heading, paragraph string) models.Item {
return models.Item{
Type: "text",
Family: "text",
Items: []models.SubItem{
{
ID: "text-sub-1",
Heading: heading,
Paragraph: paragraph,
},
},
}
}
// ListItem creates a list item with the specified items as list entries.
func ListItem(items ...string) models.Item {
subItems := make([]models.SubItem, len(items))
for i, item := range items {
subItems[i] = models.SubItem{
ID: fmt.Sprintf("list-sub-%d", i+1),
Paragraph: fmt.Sprintf("<p>%s</p>", item),
}
}
return models.Item{
Type: "list",
Family: "list",
Items: subItems,
}
}
// KnowledgeCheckItem creates a knowledge check item with the specified question and answers.
func KnowledgeCheckItem(question string, answers []models.Answer) models.Item {
return models.Item{
Type: "knowledgeCheck",
Family: "knowledge",
Items: []models.SubItem{
{
ID: "kc-sub-1",
Title: fmt.Sprintf("<p>%s</p>", question),
Answers: answers,
Feedback: "<p>Thank you for your response.</p>",
},
},
}
}
// MediaItem creates a media item (video or image) with the specified type and URL.
// The mediaType should be "video" or "image".
func MediaItem(mediaType, url string) models.Item {
var media *models.Media
switch mediaType {
case "video":
media = &models.Media{
Video: &models.VideoMedia{
Key: "video-key",
URL: url,
Type: "mp4",
Duration: 120,
OriginalURL: url,
},
}
case "image":
media = &models.Media{
Image: &models.ImageMedia{
Key: "image-key",
Type: "png",
Width: 800,
Height: 600,
OriginalURL: url,
},
}
default:
media = &models.Media{}
}
return models.Item{
Type: "multimedia",
Family: "media",
Items: []models.SubItem{
{
ID: "media-sub-1",
Title: fmt.Sprintf("<p>%s content</p>", mediaType),
Caption: fmt.Sprintf("<p>Caption for %s</p>", mediaType),
Media: media,
},
},
}
}
// InteractiveItem creates an interactive content item with the specified title.
func InteractiveItem(title string) models.Item {
return models.Item{
Type: "interactive",
Family: "interactive",
Items: []models.SubItem{
{
ID: "interactive-sub-1",
Title: fmt.Sprintf("<p>%s</p>", title),
},
},
}
}
// DividerItem creates a divider/separator item.
func DividerItem() models.Item {
return models.Item{
Type: "divider",
Family: "divider",
}
}
// FlipCardItem creates a flashcard-style item with front and back content.
func FlipCardItem(front, back string) models.Item {
return models.Item{
Type: "flashcard",
Family: "interactive",
Items: []models.SubItem{
{
ID: "flipcard-sub-1",
Front: &models.CardSide{
Description: fmt.Sprintf("<p>%s</p>", front),
},
Back: &models.CardSide{
Description: fmt.Sprintf("<p>%s</p>", back),
},
},
},
}
}
// QuoteItem creates a quote/blockquote item with text and attribution.
func QuoteItem(quote, author string) models.Item {
return models.Item{
Type: "quote",
Family: "text",
Items: []models.SubItem{
{
ID: "quote-sub-1",
Paragraph: fmt.Sprintf("<blockquote>%s</blockquote>", quote),
Title: author,
},
},
}
}
// ImageItem creates a standalone image item with caption.
func ImageItem(url, caption string) models.Item {
return models.Item{
Type: "image",
Family: "media",
Items: []models.SubItem{
{
ID: "image-sub-1",
Caption: fmt.Sprintf("<p>%s</p>", caption),
Media: &models.Media{
Image: &models.ImageMedia{
Key: "standalone-img-key",
Type: "jpg",
Width: 1200,
Height: 800,
OriginalURL: url,
},
},
},
},
}
}
// VideoItem creates a standalone video item with caption.
func VideoItem(url string, duration int, caption string) models.Item {
return models.Item{
Type: "video",
Family: "media",
Items: []models.SubItem{
{
ID: "video-sub-1",
Caption: fmt.Sprintf("<p>%s</p>", caption),
Media: &models.Media{
Video: &models.VideoMedia{
Key: "standalone-video-key",
URL: url,
Type: "mp4",
Duration: duration,
OriginalURL: url,
},
},
},
},
}
}
// MatchingItem creates a matching/pairing knowledge check item.
func MatchingItem(pairs map[string]string) models.Item {
answers := make([]models.Answer, 0, len(pairs))
i := 0
for term, match := range pairs {
answers = append(answers, models.Answer{
ID: fmt.Sprintf("match-%d", i),
Title: term,
MatchTitle: match,
Correct: true,
})
i++
}
return models.Item{
Type: "matching",
Family: "knowledge",
Items: []models.SubItem{
{
ID: "matching-sub-1",
Title: "<p>Match the following items:</p>",
Answers: answers,
Feedback: "<p>Check your matches!</p>",
},
},
}
}
// EmptyLesson returns a lesson with no items for edge case testing.
func EmptyLesson(id, title string) models.Lesson {
return models.Lesson{
ID: id,
Title: title,
Type: "lesson",
Items: []models.Item{},
}
}
// SectionLesson returns a section header (not a content lesson).
func SectionLesson(id, title string) models.Lesson {
return models.Lesson{
ID: id,
Title: title,
Type: "section",
}
}
+273
View File
@@ -0,0 +1,273 @@
package testdata
import (
"testing"
"github.com/kjanat/articulate-parser/internal/models"
)
func TestMinimalCourse(t *testing.T) {
course := MinimalCourse()
if course == nil {
t.Fatal("MinimalCourse() returned nil")
}
if course.ShareID == "" {
t.Error("MinimalCourse() should have ShareID set")
}
if course.Course.Title == "" {
t.Error("MinimalCourse() should have Title set")
}
}
func TestBasicCourse(t *testing.T) {
course := BasicCourse()
if course == nil {
t.Fatal("BasicCourse() returned nil")
}
if len(course.Course.Lessons) != 2 {
t.Errorf("BasicCourse() should have 2 lessons, got %d", len(course.Course.Lessons))
}
if course.Course.Lessons[0].Type != "section" {
t.Error("First lesson should be a section")
}
if course.Course.Lessons[1].Type != "lesson" {
t.Error("Second lesson should be a lesson")
}
if len(course.Course.Lessons[1].Items) != 1 {
t.Errorf("Lesson should have 1 item, got %d", len(course.Course.Lessons[1].Items))
}
}
func TestCompleteCourse(t *testing.T) {
course := CompleteCourse()
if course == nil {
t.Fatal("CompleteCourse() returned nil")
}
if len(course.Course.Lessons) == 0 {
t.Error("CompleteCourse() should have lessons")
}
// Verify all expected item types exist
itemTypes := make(map[string]bool)
for _, lesson := range course.Course.Lessons {
for _, item := range lesson.Items {
itemTypes[item.Type] = true
}
}
expectedTypes := []string{"text", "list", "divider", "multimedia", "knowledgeCheck", "interactive", "flashcard", "quote"}
for _, et := range expectedTypes {
if !itemTypes[et] {
t.Errorf("CompleteCourse() should have item type %q", et)
}
}
}
func TestLargeCourse(t *testing.T) {
tests := []struct {
lessons int
expected int
}{
{1, 2}, // 1 lesson + 1 section
{5, 6}, // 5 lessons + 1 section
{10, 12}, // 10 lessons + 2 sections
{50, 60}, // 50 lessons + 10 sections
}
for _, tt := range tests {
course := LargeCourse(tt.lessons)
if course == nil {
t.Fatalf("LargeCourse(%d) returned nil", tt.lessons)
}
if len(course.Course.Lessons) != tt.expected {
t.Errorf("LargeCourse(%d) should have %d lessons+sections, got %d",
tt.lessons, tt.expected, len(course.Course.Lessons))
}
}
}
func TestTextItem(t *testing.T) {
item := TextItem("Test Heading", "<p>Test paragraph</p>")
if item.Type != "text" {
t.Errorf("TextItem Type should be 'text', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("TextItem should have 1 sub-item, got %d", len(item.Items))
}
if item.Items[0].Heading != "Test Heading" {
t.Errorf("TextItem heading mismatch: got %q", item.Items[0].Heading)
}
}
func TestListItem(t *testing.T) {
item := ListItem("Item 1", "Item 2", "Item 3")
if item.Type != "list" {
t.Errorf("ListItem Type should be 'list', got %q", item.Type)
}
if len(item.Items) != 3 {
t.Errorf("ListItem should have 3 sub-items, got %d", len(item.Items))
}
}
func TestKnowledgeCheckItem(t *testing.T) {
answers := []models.Answer{
{Title: "A", Correct: false},
{Title: "B", Correct: true},
}
item := KnowledgeCheckItem("Test question?", answers)
if item.Type != "knowledgeCheck" {
t.Errorf("KnowledgeCheckItem Type should be 'knowledgeCheck', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("KnowledgeCheckItem should have 1 sub-item, got %d", len(item.Items))
}
if len(item.Items[0].Answers) != 2 {
t.Errorf("KnowledgeCheckItem should have 2 answers, got %d", len(item.Items[0].Answers))
}
}
func TestMediaItem(t *testing.T) {
tests := []struct {
name string
mediaType string
hasVideo bool
hasImage bool
}{
{"video", "video", true, false},
{"image", "image", false, true},
{"unknown", "unknown", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
item := MediaItem(tt.mediaType, "https://example.com/media")
if item.Type != "multimedia" {
t.Errorf("MediaItem Type should be 'multimedia', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("MediaItem should have 1 sub-item, got %d", len(item.Items))
}
if tt.hasVideo && item.Items[0].Media.Video == nil {
t.Error("Expected video media to be set")
}
if tt.hasImage && item.Items[0].Media.Image == nil {
t.Error("Expected image media to be set")
}
})
}
}
func TestInteractiveItem(t *testing.T) {
item := InteractiveItem("Interactive Title")
if item.Type != "interactive" {
t.Errorf("InteractiveItem Type should be 'interactive', got %q", item.Type)
}
}
func TestDividerItem(t *testing.T) {
item := DividerItem()
if item.Type != "divider" {
t.Errorf("DividerItem Type should be 'divider', got %q", item.Type)
}
}
func TestFlipCardItem(t *testing.T) {
item := FlipCardItem("Front text", "Back text")
if item.Type != "flashcard" {
t.Errorf("FlipCardItem Type should be 'flashcard', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("FlipCardItem should have 1 sub-item, got %d", len(item.Items))
}
if item.Items[0].Front == nil || item.Items[0].Back == nil {
t.Error("FlipCardItem should have Front and Back set")
}
}
func TestQuoteItem(t *testing.T) {
item := QuoteItem("Test quote", "Test author")
if item.Type != "quote" {
t.Errorf("QuoteItem Type should be 'quote', got %q", item.Type)
}
}
func TestImageItem(t *testing.T) {
item := ImageItem("https://example.com/img.jpg", "Image caption")
if item.Type != "image" {
t.Errorf("ImageItem Type should be 'image', got %q", item.Type)
}
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Image == nil {
t.Error("ImageItem should have image media set")
}
}
func TestVideoItem(t *testing.T) {
item := VideoItem("https://example.com/video.mp4", 120, "Video caption")
if item.Type != "video" {
t.Errorf("VideoItem Type should be 'video', got %q", item.Type)
}
if len(item.Items) != 1 || item.Items[0].Media == nil || item.Items[0].Media.Video == nil {
t.Error("VideoItem should have video media set")
}
if item.Items[0].Media.Video.Duration != 120 {
t.Errorf("VideoItem duration should be 120, got %d", item.Items[0].Media.Video.Duration)
}
}
func TestMatchingItem(t *testing.T) {
pairs := map[string]string{
"Term 1": "Match 1",
"Term 2": "Match 2",
}
item := MatchingItem(pairs)
if item.Type != "matching" {
t.Errorf("MatchingItem Type should be 'matching', got %q", item.Type)
}
if len(item.Items) != 1 {
t.Fatalf("MatchingItem should have 1 sub-item, got %d", len(item.Items))
}
if len(item.Items[0].Answers) != 2 {
t.Errorf("MatchingItem should have 2 answers, got %d", len(item.Items[0].Answers))
}
}
func TestEmptyLesson(t *testing.T) {
lesson := EmptyLesson("test-id", "Test Title")
if lesson.ID != "test-id" {
t.Errorf("EmptyLesson ID mismatch: got %q", lesson.ID)
}
if lesson.Type != "lesson" {
t.Errorf("EmptyLesson Type should be 'lesson', got %q", lesson.Type)
}
if len(lesson.Items) != 0 {
t.Errorf("EmptyLesson should have no items, got %d", len(lesson.Items))
}
}
func TestSectionLesson(t *testing.T) {
lesson := SectionLesson("section-id", "Section Title")
if lesson.Type != "section" {
t.Errorf("SectionLesson Type should be 'section', got %q", lesson.Type)
}
}
func TestSampleVariables(t *testing.T) {
if len(SampleAnswers) == 0 {
t.Error("SampleAnswers should not be empty")
}
if len(SampleSubItems) == 0 {
t.Error("SampleSubItems should not be empty")
}
// Verify one correct answer exists in SampleAnswers
hasCorrect := false
for _, a := range SampleAnswers {
if a.Correct {
hasCorrect = true
break
}
}
if !hasCorrect {
t.Error("SampleAnswers should have at least one correct answer")
}
}
+11 -11
View File
@@ -7,11 +7,12 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/signal"
"strings" "strings"
"syscall"
"github.com/kjanat/articulate-parser/internal/config" "github.com/kjanat/articulate-parser/internal/config"
"github.com/kjanat/articulate-parser/internal/exporters" "github.com/kjanat/articulate-parser/internal/exporters"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/services" "github.com/kjanat/articulate-parser/internal/services"
"github.com/kjanat/articulate-parser/internal/version" "github.com/kjanat/articulate-parser/internal/version"
) )
@@ -26,20 +27,19 @@ func main() {
// run contains the main application logic and returns an exit code. // run contains the main application logic and returns an exit code.
// This function is testable as it doesn't call os.Exit directly. // This function is testable as it doesn't call os.Exit directly.
func run(args []string) int { func run(args []string) int {
// Create context that cancels on interrupt signals (SIGINT, SIGTERM)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Load configuration // Load configuration
cfg := config.Load() cfg := config.Load()
// Dependency injection setup with configuration // Dependency injection setup with configuration
var logger interfaces.Logger logger := services.NewLoggerFromConfig(cfg)
if cfg.LogFormat == "json" {
logger = services.NewSlogLogger(cfg.LogLevel)
} else {
logger = services.NewTextLogger(cfg.LogLevel)
}
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout) parser := services.NewArticulateParser(logger, cfg)
exporterFactory := exporters.NewFactory(htmlCleaner) exporterFactory := exporters.NewFactory(htmlCleaner, logger)
app := services.NewApp(parser, exporterFactory) app := services.NewApp(parser, exporterFactory)
// Check for version flag // Check for version flag
@@ -70,9 +70,9 @@ func run(args []string) int {
// Determine if source is a URI or file path // Determine if source is a URI or file path
if isURI(source) { if isURI(source) {
err = app.ProcessCourseFromURI(context.Background(), source, format, output) err = app.ProcessCourseFromURI(ctx, source, format, output)
} else { } else {
err = app.ProcessCourseFromFile(source, format, output) err = app.ProcessCourseFromFile(ctx, source, format, output)
} }
if err != nil { if err != nil {