mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-10-13 00:55:27 +02:00
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:
+25
-24
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & 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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# Example Course
|
|
||||||
|
|
||||||
Course description
|
|
||||||
|
|
||||||
## Course Information
|
|
||||||
|
|
||||||
- **Course ID**:
|
|
||||||
- **Share ID**: example-id
|
|
||||||
- **Navigation Mode**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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&T")
|
||||||
|
f.Add("<script>alert('xss')</script>")
|
||||||
|
f.Add(" ")
|
||||||
|
f.Add("'apostrophe'")
|
||||||
|
f.Add(""quoted"")
|
||||||
|
f.Add("éïö")
|
||||||
|
f.Add("'hex entity'")
|
||||||
|
f.Add("’numeric entity’")
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Vendored
+467
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+273
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user