diff --git a/.dprint.jsonc b/.dprint.jsonc index 57b4810..4950596 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -1,42 +1,43 @@ { - "typescript": { - }, - "json": { - }, - "markdown": { - }, - "toml": { - }, - "dockerfile": { - }, - "oxc": { - }, - "ruff": { - }, - "jupyter": { - }, - "malva": { - }, - "markup": { - }, - "yaml": { - }, - "excludes": [ - "**/node_modules", - "**/*-lock.json", - ], - "plugins": [ - "https://plugins.dprint.dev/typescript-0.95.13.wasm", - "https://plugins.dprint.dev/json-0.21.1.wasm", - "https://plugins.dprint.dev/markdown-0.20.0.wasm", - "https://plugins.dprint.dev/toml-0.7.0.wasm", - "https://plugins.dprint.dev/dockerfile-0.3.3.wasm", - "https://plugins.dprint.dev/oxc-0.1.0.wasm", - "https://plugins.dprint.dev/ruff-0.6.11.wasm", - "https://plugins.dprint.dev/jupyter-0.2.1.wasm", - "https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm", - "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm", - "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm", - "https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364", - ], + "$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, + }, + ], + }, + "excludes": ["**/node_modules", "**/*-lock.json"], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.95.13.wasm", + "https://plugins.dprint.dev/json-0.21.1.wasm", + "https://plugins.dprint.dev/markdown-0.20.0.wasm", + "https://plugins.dprint.dev/toml-0.7.0.wasm", + "https://plugins.dprint.dev/dockerfile-0.3.3.wasm", + "https://plugins.dprint.dev/oxc-0.1.0.wasm", + "https://plugins.dprint.dev/ruff-0.6.11.wasm", + "https://plugins.dprint.dev/jupyter-0.2.1.wasm", + "https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm", + "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm", + "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm", + "https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364", + ], } diff --git a/.golangci.yml b/.golangci.yml index 75daaba..8e3f690 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,7 @@ +# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json # golangci-lint configuration for articulate-parser # https://golangci-lint.run/usage/configuration/ - +--- version: "2" # Options for analysis running @@ -51,6 +52,7 @@ formatters: - gofmt - goimports - gofumpt + exclusions: { paths: [".zed"] } settings: # gofmt settings gofmt: diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..f83ae06 --- /dev/null +++ b/.opencode/opencode.jsonc @@ -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"], + }, + }, +} diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..246318d --- /dev/null +++ b/.zed/settings.json @@ -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" + ] + } + } + } +} diff --git a/internal/exporters/bench_test.go b/internal/exporters/bench_test.go index 3a485b6..1dea7e7 100644 --- a/internal/exporters/bench_test.go +++ b/internal/exporters/bench_test.go @@ -6,12 +6,13 @@ import ( "github.com/kjanat/articulate-parser/internal/models" "github.com/kjanat/articulate-parser/internal/services" + "github.com/kjanat/articulate-parser/internal/testdata" ) // BenchmarkFactory_CreateExporter_Markdown benchmarks markdown exporter creation. func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) b.ResetTimer() for b.Loop() { @@ -22,7 +23,7 @@ func BenchmarkFactory_CreateExporter_Markdown(b *testing.B) { // BenchmarkFactory_CreateExporter_All benchmarks creating all exporter types. func BenchmarkFactory_CreateExporter_All(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) formats := []string{"markdown", "docx", "html"} b.ResetTimer() @@ -36,15 +37,15 @@ func BenchmarkFactory_CreateExporter_All(b *testing.B) { // BenchmarkAllExporters_Export benchmarks all exporters with the same course. func BenchmarkAllExporters_Export(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - course := createBenchmarkCourse() + course := testdata.BasicCourse() exporters := map[string]struct { exporter any ext string }{ - "Markdown": {NewMarkdownExporter(htmlCleaner), ".md"}, - "Docx": {NewDocxExporter(htmlCleaner), ".docx"}, - "HTML": {NewHTMLExporter(htmlCleaner), ".html"}, + "Markdown": {NewMarkdownExporter(htmlCleaner, nil), ".md"}, + "Docx": {NewDocxExporter(htmlCleaner, nil), ".docx"}, + "HTML": {NewHTMLExporter(htmlCleaner, nil), ".html"}, } for name, exp := range exporters { @@ -66,10 +67,10 @@ func BenchmarkAllExporters_Export(b *testing.B) { // BenchmarkExporters_LargeCourse benchmarks exporters with large course data. func BenchmarkExporters_LargeCourse(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - course := createLargeBenchmarkCourse() + course := testdata.LargeCourse(50) b.Run("Markdown_Large", func(b *testing.B) { - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) tempDir := b.TempDir() b.ResetTimer() @@ -80,7 +81,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) { }) b.Run("Docx_Large", func(b *testing.B) { - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) tempDir := b.TempDir() b.ResetTimer() @@ -91,7 +92,7 @@ func BenchmarkExporters_LargeCourse(b *testing.B) { }) b.Run("HTML_Large", func(b *testing.B) { - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) tempDir := b.TempDir() b.ResetTimer() @@ -101,100 +102,3 @@ func BenchmarkExporters_LargeCourse(b *testing.B) { } }) } - -// createBenchmarkCourse creates a standard-sized course for benchmarking. -func createBenchmarkCourse() *models.Course { - return &models.Course{ - ShareID: "benchmark-id", - Author: "Benchmark Author", - Course: models.CourseInfo{ - ID: "bench-course", - Title: "Benchmark Course", - Description: "Performance testing course", - NavigationMode: "menu", - Lessons: []models.Lesson{ - { - ID: "lesson1", - Title: "Introduction", - Type: "lesson", - Items: []models.Item{ - { - Type: "text", - Items: []models.SubItem{ - { - Heading: "Welcome", - Paragraph: "

This is a test paragraph with HTML content.

", - }, - }, - }, - { - 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: "

This is lesson description with formatting.

", - Items: []models.Item{ - { - Type: "text", - Items: []models.SubItem{ - { - Heading: "Section Heading", - Paragraph: "

Content with bold and italic text.

", - }, - }, - }, - { - 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, - }, - } -} diff --git a/internal/exporters/docx.go b/internal/exporters/docx.go index 4c34bdf..0c32b26 100644 --- a/internal/exporters/docx.go +++ b/internal/exporters/docx.go @@ -29,19 +29,23 @@ const ( type DocxExporter struct { // htmlCleaner is used to convert HTML content to plain text htmlCleaner *services.HTMLCleaner + // logger is used for logging warnings and errors + logger interfaces.Logger } // NewDocxExporter creates a new DocxExporter instance. -// It takes an HTMLCleaner to handle HTML content conversion. +// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging. // // Parameters: // - htmlCleaner: Service for cleaning HTML content in course data +// - logger: Logger instance for warnings and errors // // Returns: // - An implementation of the Exporter interface for DOCX format -func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { +func NewDocxExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { return &DocxExporter{ htmlCleaner: htmlCleaner, + logger: logger, } } @@ -91,7 +95,7 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error { // affect the validity of the exported file. defer func() { if err := file.Close(); err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", err) + e.logger.Warn("failed to close output file", "error", err) } }() diff --git a/internal/exporters/docx_test.go b/internal/exporters/docx_test.go index 9488d6b..fbfc138 100644 --- a/internal/exporters/docx_test.go +++ b/internal/exporters/docx_test.go @@ -12,7 +12,7 @@ import ( // TestNewDocxExporter tests the NewDocxExporter constructor. func TestNewDocxExporter(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) if exporter == nil { t.Fatal("NewDocxExporter() returned nil") @@ -32,7 +32,7 @@ func TestNewDocxExporter(t *testing.T) { // TestDocxExporter_SupportedFormat tests the SupportedFormat method. func TestDocxExporter_SupportedFormat(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) expected := "docx" result := exporter.SupportedFormat() @@ -45,7 +45,7 @@ func TestDocxExporter_SupportedFormat(t *testing.T) { // TestDocxExporter_Export tests the Export method. func TestDocxExporter_Export(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create test course testCourse := createTestCourseForDocx() @@ -79,7 +79,7 @@ func TestDocxExporter_Export(t *testing.T) { // TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically. func TestDocxExporter_Export_AddDocxExtension(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) testCourse := createTestCourseForDocx() @@ -102,7 +102,7 @@ func TestDocxExporter_Export_AddDocxExtension(t *testing.T) { // TestDocxExporter_Export_InvalidPath tests export with invalid output path. func TestDocxExporter_Export_InvalidPath(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) testCourse := createTestCourseForDocx() @@ -118,7 +118,7 @@ func TestDocxExporter_Export_InvalidPath(t *testing.T) { // TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export. func TestDocxExporter_ExportLesson(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create course with specific lesson content course := &models.Course{ @@ -165,7 +165,7 @@ func TestDocxExporter_ExportLesson(t *testing.T) { // TestDocxExporter_ExportItem tests the exportItem method indirectly through Export. func TestDocxExporter_ExportItem(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create course with different item types course := &models.Course{ @@ -231,7 +231,7 @@ func TestDocxExporter_ExportItem(t *testing.T) { // TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export. func TestDocxExporter_ExportSubItem(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create course with sub-item containing all possible fields course := &models.Course{ @@ -284,7 +284,7 @@ func TestDocxExporter_ExportSubItem(t *testing.T) { // TestDocxExporter_ComplexCourse tests export of a complex course structure. func TestDocxExporter_ComplexCourse(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create complex test course course := &models.Course{ @@ -421,7 +421,7 @@ func TestDocxExporter_ComplexCourse(t *testing.T) { // TestDocxExporter_EmptyCourse tests export of an empty course. func TestDocxExporter_EmptyCourse(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create minimal course course := &models.Course{ @@ -450,7 +450,7 @@ func TestDocxExporter_EmptyCourse(t *testing.T) { // TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned. func TestDocxExporter_HTMLCleaning(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create course with HTML content that needs cleaning course := &models.Course{ @@ -498,7 +498,7 @@ func TestDocxExporter_HTMLCleaning(t *testing.T) { // TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved. func TestDocxExporter_ExistingDocxExtension(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) testCourse := createTestCourseForDocx() @@ -526,7 +526,7 @@ func TestDocxExporter_ExistingDocxExtension(t *testing.T) { // TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive. func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) testCourse := createTestCourseForDocx() @@ -599,7 +599,7 @@ func createTestCourseForDocx() *models.Course { // BenchmarkDocxExporter_Export benchmarks the Export method. func BenchmarkDocxExporter_Export(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) course := createTestCourseForDocx() // Create temporary directory @@ -618,7 +618,7 @@ func BenchmarkDocxExporter_Export(b *testing.B) { // BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course. func BenchmarkDocxExporter_ComplexCourse(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewDocxExporter(htmlCleaner) + exporter := NewDocxExporter(htmlCleaner, nil) // Create complex course for benchmarking course := &models.Course{ diff --git a/internal/exporters/example_test.go b/internal/exporters/example_test.go index a897b80..81ebea1 100644 --- a/internal/exporters/example_test.go +++ b/internal/exporters/example_test.go @@ -13,7 +13,7 @@ import ( // ExampleNewFactory demonstrates creating an exporter factory. func ExampleNewFactory() { htmlCleaner := services.NewHTMLCleaner() - factory := exporters.NewFactory(htmlCleaner) + factory := exporters.NewFactory(htmlCleaner, nil) // Get supported formats formats := factory.SupportedFormats() @@ -24,7 +24,7 @@ func ExampleNewFactory() { // ExampleFactory_CreateExporter demonstrates creating exporters. func ExampleFactory_CreateExporter() { htmlCleaner := services.NewHTMLCleaner() - factory := exporters.NewFactory(htmlCleaner) + factory := exporters.NewFactory(htmlCleaner, nil) // Create a markdown exporter exporter, err := factory.CreateExporter("markdown") @@ -39,7 +39,7 @@ func ExampleFactory_CreateExporter() { // ExampleFactory_CreateExporter_caseInsensitive demonstrates case-insensitive format names. func ExampleFactory_CreateExporter_caseInsensitive() { htmlCleaner := services.NewHTMLCleaner() - factory := exporters.NewFactory(htmlCleaner) + factory := exporters.NewFactory(htmlCleaner, nil) // All these work (case-insensitive) formats := []string{"MARKDOWN", "Markdown", "markdown", "MD"} @@ -58,7 +58,7 @@ func ExampleFactory_CreateExporter_caseInsensitive() { // ExampleMarkdownExporter_Export demonstrates exporting to Markdown. func ExampleMarkdownExporter_Export() { htmlCleaner := services.NewHTMLCleaner() - exporter := exporters.NewMarkdownExporter(htmlCleaner) + exporter := exporters.NewMarkdownExporter(htmlCleaner, nil) course := &models.Course{ ShareID: "example-id", @@ -81,7 +81,7 @@ func ExampleMarkdownExporter_Export() { // ExampleDocxExporter_Export demonstrates exporting to DOCX. func ExampleDocxExporter_Export() { htmlCleaner := services.NewHTMLCleaner() - exporter := exporters.NewDocxExporter(htmlCleaner) + exporter := exporters.NewDocxExporter(htmlCleaner, nil) course := &models.Course{ ShareID: "example-id", @@ -99,3 +99,33 @@ func ExampleDocxExporter_Export() { fmt.Println("DOCX export complete") // Output: DOCX export complete } + +// ExampleHTMLExporter_Export demonstrates exporting to HTML. +func ExampleHTMLExporter_Export() { + htmlCleaner := services.NewHTMLCleaner() + exporter := exporters.NewHTMLExporter(htmlCleaner, nil) + + course := &models.Course{ + ShareID: "example-id", + Course: models.CourseInfo{ + Title: "Example Course", + Description: "

A sample course

", + 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 +} diff --git a/internal/exporters/factory.go b/internal/exporters/factory.go index 9291449..e3623b1 100644 --- a/internal/exporters/factory.go +++ b/internal/exporters/factory.go @@ -25,20 +25,24 @@ const ( type Factory struct { // htmlCleaner is used by exporters to convert HTML content to plain text htmlCleaner *services.HTMLCleaner + // logger is used by exporters for logging + logger interfaces.Logger } // NewFactory creates a new exporter factory. -// It takes an HTMLCleaner instance that will be passed to the exporters +// It takes an HTMLCleaner instance and a logger that will be passed to the exporters // created by this factory. // // Parameters: // - htmlCleaner: Service for cleaning HTML content in course data +// - logger: Logger instance for exporter logging // // Returns: // - An implementation of the ExporterFactory interface -func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory { +func NewFactory(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.ExporterFactory { return &Factory{ htmlCleaner: htmlCleaner, + logger: logger, } } @@ -47,11 +51,11 @@ func NewFactory(htmlCleaner *services.HTMLCleaner) interfaces.ExporterFactory { func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) { switch strings.ToLower(format) { case FormatMarkdown, formatAliasMarkdown: - return NewMarkdownExporter(f.htmlCleaner), nil + return NewMarkdownExporter(f.htmlCleaner, f.logger), nil case FormatDocx, formatAliasDocx: - return NewDocxExporter(f.htmlCleaner), nil + return NewDocxExporter(f.htmlCleaner, f.logger), nil case FormatHTML, formatAliasHTML: - return NewHTMLExporter(f.htmlCleaner), nil + return NewHTMLExporter(f.htmlCleaner, f.logger), nil default: return nil, fmt.Errorf("unsupported export format: %s", format) } diff --git a/internal/exporters/factory_test.go b/internal/exporters/factory_test.go index 8379e14..d99fe4e 100644 --- a/internal/exporters/factory_test.go +++ b/internal/exporters/factory_test.go @@ -12,7 +12,7 @@ import ( // TestNewFactory tests the NewFactory constructor. func TestNewFactory(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) if factory == nil { t.Fatal("NewFactory() returned nil") @@ -32,7 +32,7 @@ func TestNewFactory(t *testing.T) { // TestFactory_CreateExporter tests the CreateExporter method for all supported formats. func TestFactory_CreateExporter(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) testCases := []struct { name string @@ -135,7 +135,7 @@ func TestFactory_CreateExporter(t *testing.T) { // TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive. func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) testCases := []struct { format string @@ -182,7 +182,7 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) { // TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats. func TestFactory_CreateExporter_ErrorMessages(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) testCases := []string{ "pdf", @@ -222,7 +222,7 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) { // TestFactory_SupportedFormats tests the SupportedFormats method. func TestFactory_SupportedFormats(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) formats := factory.SupportedFormats() @@ -255,7 +255,7 @@ func TestFactory_SupportedFormats(t *testing.T) { // TestFactory_SupportedFormats_Immutable tests that the returned slice is safe to modify. func TestFactory_SupportedFormats_Immutable(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) // Get formats twice formats1 := factory.SupportedFormats() @@ -281,7 +281,7 @@ func TestFactory_SupportedFormats_Immutable(t *testing.T) { // TestFactory_ExporterTypes tests that created exporters are of correct types. func TestFactory_ExporterTypes(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) // Test markdown exporter markdownExporter, err := factory.CreateExporter("markdown") @@ -307,7 +307,7 @@ func TestFactory_ExporterTypes(t *testing.T) { // TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters. func TestFactory_HTMLCleanerPropagation(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) // Test with markdown exporter markdownExporter, err := factory.CreateExporter("markdown") @@ -358,7 +358,7 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) { // TestFactory_MultipleExporterCreation tests creating multiple exporters of same type. func TestFactory_MultipleExporterCreation(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) // Create multiple markdown exporters exporter1, err := factory.CreateExporter("markdown") @@ -395,7 +395,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) { } }() - factory := NewFactory(nil) + factory := NewFactory(nil, nil) if factory == nil { t.Fatal("NewFactory(nil) returned nil") @@ -412,7 +412,7 @@ func TestFactory_WithNilHTMLCleaner(t *testing.T) { // TestFactory_FormatNormalization tests that format strings are properly normalized. func TestFactory_FormatNormalization(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) // Test formats with extra whitespace testCases := []struct { @@ -445,7 +445,7 @@ func TestFactory_FormatNormalization(t *testing.T) { // BenchmarkFactory_CreateExporter benchmarks the CreateExporter method. func BenchmarkFactory_CreateExporter(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) for b.Loop() { _, _ = factory.CreateExporter("markdown") @@ -455,7 +455,7 @@ func BenchmarkFactory_CreateExporter(b *testing.B) { // BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters. func BenchmarkFactory_CreateExporter_Docx(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) for b.Loop() { _, _ = factory.CreateExporter("docx") @@ -465,7 +465,7 @@ func BenchmarkFactory_CreateExporter_Docx(b *testing.B) { // BenchmarkFactory_SupportedFormats benchmarks the SupportedFormats method. func BenchmarkFactory_SupportedFormats(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - factory := NewFactory(htmlCleaner) + factory := NewFactory(htmlCleaner, nil) for b.Loop() { _ = factory.SupportedFormats() diff --git a/internal/exporters/html.go b/internal/exporters/html.go index 9105e72..9d7dfa5 100644 --- a/internal/exporters/html.go +++ b/internal/exporters/html.go @@ -25,17 +25,21 @@ type HTMLExporter struct { htmlCleaner *services.HTMLCleaner // tmpl holds the parsed HTML template tmpl *template.Template + // logger is used for logging warnings and errors + logger interfaces.Logger } // NewHTMLExporter creates a new HTMLExporter instance. -// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed. +// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed +// and a logger for logging. // // Parameters: // - htmlCleaner: Service for cleaning HTML content in course data +// - logger: Logger instance for warnings and errors // // Returns: // - An implementation of the Exporter interface for HTML format -func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { +func NewHTMLExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { // Parse the template with custom functions funcMap := template.FuncMap{ "safeHTML": func(s string) template.HTML { @@ -51,6 +55,7 @@ func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { return &HTMLExporter{ htmlCleaner: htmlCleaner, tmpl: tmpl, + logger: logger, } } diff --git a/internal/exporters/html_styles.css b/internal/exporters/html_styles.css index 7952763..6a4c2aa 100644 --- a/internal/exporters/html_styles.css +++ b/internal/exporters/html_styles.css @@ -1,175 +1,175 @@ body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - Cantarell, sans-serif; - line-height: 1.6; - color: #333; - max-width: 800px; - margin: 0 auto; - padding: 20px; - background-color: #f9f9f9; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + Cantarell, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: #f9f9f9; } header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 2rem; - border-radius: 10px; - margin-bottom: 2rem; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2rem; + border-radius: 10px; + margin-bottom: 2rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } header h1 { - margin: 0; - font-size: 2.5rem; - font-weight: 300; + margin: 0; + font-size: 2.5rem; + font-weight: 300; } .course-description { - margin-top: 1rem; - font-size: 1.1rem; - opacity: 0.9; + margin-top: 1rem; + font-size: 1.1rem; + opacity: 0.9; } .course-info { - background: white; - padding: 1.5rem; - border-radius: 8px; - margin-bottom: 2rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: white; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 2rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .course-info h2 { - margin-top: 0; - color: #4a5568; - border-bottom: 2px solid #e2e8f0; - padding-bottom: 0.5rem; + margin-top: 0; + color: #4a5568; + border-bottom: 2px solid #e2e8f0; + padding-bottom: 0.5rem; } .course-info ul { - list-style: none; - padding: 0; + list-style: none; + padding: 0; } .course-info li { - margin: 0.5rem 0; - padding: 0.5rem; - background: #f7fafc; - border-radius: 4px; + margin: 0.5rem 0; + padding: 0.5rem; + background: #f7fafc; + border-radius: 4px; } .course-section { - background: #4299e1; - color: white; - padding: 1.5rem; - border-radius: 8px; - margin: 2rem 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: #4299e1; + color: white; + padding: 1.5rem; + border-radius: 8px; + margin: 2rem 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .course-section h2 { - margin: 0; - font-weight: 400; + margin: 0; + font-weight: 400; } .lesson { - background: white; - padding: 2rem; - border-radius: 8px; - margin: 2rem 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - border-left: 4px solid #4299e1; + background: white; + padding: 2rem; + border-radius: 8px; + margin: 2rem 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-left: 4px solid #4299e1; } .lesson h3 { - margin-top: 0; - color: #2d3748; - font-size: 1.5rem; + margin-top: 0; + color: #2d3748; + font-size: 1.5rem; } .lesson-description { - margin: 1rem 0; - padding: 1rem; - background: #f7fafc; - border-radius: 4px; - border-left: 3px solid #4299e1; + margin: 1rem 0; + padding: 1rem; + background: #f7fafc; + border-radius: 4px; + border-left: 3px solid #4299e1; } .item { - margin: 1.5rem 0; - padding: 1rem; - border-radius: 6px; - background: #fafafa; - border: 1px solid #e2e8f0; + margin: 1.5rem 0; + padding: 1rem; + border-radius: 6px; + background: #fafafa; + border: 1px solid #e2e8f0; } .item h4 { - margin-top: 0; - color: #4a5568; - font-size: 1.2rem; - text-transform: capitalize; + margin-top: 0; + color: #4a5568; + font-size: 1.2rem; + text-transform: capitalize; } .text-item { - background: #f0fff4; - border-left: 3px solid #48bb78; + background: #f0fff4; + border-left: 3px solid #48bb78; } .list-item { - background: #fffaf0; - border-left: 3px solid #ed8936; + background: #fffaf0; + border-left: 3px solid #ed8936; } .knowledge-check { - background: #e6fffa; - border-left: 3px solid #38b2ac; + background: #e6fffa; + border-left: 3px solid #38b2ac; } .multimedia-item { - background: #faf5ff; - border-left: 3px solid #9f7aea; + background: #faf5ff; + border-left: 3px solid #9f7aea; } .interactive-item { - background: #fff5f5; - border-left: 3px solid #f56565; + background: #fff5f5; + border-left: 3px solid #f56565; } .unknown-item { - background: #f7fafc; - border-left: 3px solid #a0aec0; + background: #f7fafc; + border-left: 3px solid #a0aec0; } .answers { - margin: 1rem 0; + margin: 1rem 0; } .answers h5 { - margin: 0.5rem 0; - color: #4a5568; + margin: 0.5rem 0; + color: #4a5568; } .answers ol { - margin: 0.5rem 0; - padding-left: 1.5rem; + margin: 0.5rem 0; + padding-left: 1.5rem; } .answers li { - margin: 0.3rem 0; - padding: 0.3rem; + margin: 0.3rem 0; + padding: 0.3rem; } .correct-answer { - background: #c6f6d5; - border-radius: 3px; - font-weight: bold; + background: #c6f6d5; + border-radius: 3px; + font-weight: bold; } .correct-answer::after { - content: " ✓"; - color: #38a169; + content: " ✓"; + color: #38a169; } .feedback { - margin: 1rem 0; - padding: 1rem; - background: #edf2f7; - border-radius: 4px; - border-left: 3px solid #4299e1; - font-style: italic; + margin: 1rem 0; + padding: 1rem; + background: #edf2f7; + border-radius: 4px; + border-left: 3px solid #4299e1; + font-style: italic; } .media-info { - background: #edf2f7; - padding: 1rem; - border-radius: 4px; - margin: 0.5rem 0; + background: #edf2f7; + padding: 1rem; + border-radius: 4px; + margin: 0.5rem 0; } .media-info strong { - color: #4a5568; + color: #4a5568; } hr { - border: none; - height: 2px; - background: linear-gradient(to right, #667eea, #764ba2); - margin: 2rem 0; - border-radius: 1px; + border: none; + height: 2px; + background: linear-gradient(to right, #667eea, #764ba2); + margin: 2rem 0; + border-radius: 1px; } ul { - padding-left: 1.5rem; + padding-left: 1.5rem; } li { - margin: 0.5rem 0; + margin: 0.5rem 0; } diff --git a/internal/exporters/html_test.go b/internal/exporters/html_test.go index 3f2fe13..1d8f389 100644 --- a/internal/exporters/html_test.go +++ b/internal/exporters/html_test.go @@ -13,7 +13,7 @@ import ( // TestNewHTMLExporter tests the NewHTMLExporter constructor. func TestNewHTMLExporter(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) if exporter == nil { t.Fatal("NewHTMLExporter() returned nil") @@ -37,7 +37,7 @@ func TestNewHTMLExporter(t *testing.T) { // TestHTMLExporter_SupportedFormat tests the SupportedFormat method. func TestHTMLExporter_SupportedFormat(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) expected := "html" result := exporter.SupportedFormat() @@ -50,7 +50,7 @@ func TestHTMLExporter_SupportedFormat(t *testing.T) { // TestHTMLExporter_Export tests the Export method. func TestHTMLExporter_Export(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) // Create test course testCourse := createTestCourseForHTML() @@ -129,7 +129,7 @@ func TestHTMLExporter_Export(t *testing.T) { // TestHTMLExporter_Export_InvalidPath tests export with invalid output path. func TestHTMLExporter_Export_InvalidPath(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) testCourse := createTestCourseForHTML() @@ -145,7 +145,7 @@ func TestHTMLExporter_Export_InvalidPath(t *testing.T) { // TestHTMLExporter_ComplexCourse tests export of a course with complex content. func TestHTMLExporter_ComplexCourse(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) // Create complex test course course := &models.Course{ @@ -276,7 +276,7 @@ func TestHTMLExporter_ComplexCourse(t *testing.T) { // TestHTMLExporter_EmptyCourse tests export of an empty course. func TestHTMLExporter_EmptyCourse(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) // Create minimal course course := &models.Course{ @@ -324,7 +324,7 @@ func TestHTMLExporter_EmptyCourse(t *testing.T) { // TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled. func TestHTMLExporter_HTMLCleaning(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) // Create course with HTML content that needs cleaning in some places course := &models.Course{ @@ -392,6 +392,227 @@ func TestHTMLExporter_HTMLCleaning(t *testing.T) { } } +// TestHTMLExporter_Export_ErrorPaths tests various error conditions for the HTML exporter. +func TestHTMLExporter_Export_ErrorPaths(t *testing.T) { + htmlCleaner := services.NewHTMLCleaner() + exporter := NewHTMLExporter(htmlCleaner, nil) + + tests := []struct { + name string + course *models.Course + outputPath string + wantErr bool + errContains string + }{ + { + name: "non-existent directory", + course: createTestCourseForHTML(), + outputPath: "/nonexistent/deeply/nested/path/output.html", + wantErr: true, + errContains: "failed to create file", + }, + { + name: "empty output path", + course: createTestCourseForHTML(), + outputPath: "", + wantErr: true, + errContains: "failed to create file", + }, + { + name: "output path is directory", + course: createTestCourseForHTML(), + outputPath: t.TempDir(), // directory, not a file + wantErr: true, + errContains: "failed to create file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := exporter.Export(tt.course, tt.outputPath) + + if tt.wantErr { + if err == nil { + t.Errorf("Export() expected error containing %q, got nil", tt.errContains) + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Export() error = %v, want error containing %q", err, tt.errContains) + } + } else if err != nil { + t.Errorf("Export() unexpected error = %v", err) + } + }) + } +} + +// TestHTMLExporter_WriteHTML_ErrorPaths tests WriteHTML error conditions. +func TestHTMLExporter_WriteHTML_ErrorPaths(t *testing.T) { + htmlCleaner := services.NewHTMLCleaner() + exporter := NewHTMLExporter(htmlCleaner, nil) + htmlExporter := exporter.(*HTMLExporter) + + tests := []struct { + name string + course *models.Course + writer *failingWriter + wantErr bool + errContains string + }{ + { + name: "writer fails immediately", + course: createTestCourseForHTML(), + writer: &failingWriter{failAfter: 0}, + wantErr: true, + errContains: "failed to execute template", + }, + { + name: "writer fails mid-write", + course: createTestCourseForHTML(), + writer: &failingWriter{failAfter: 100}, + wantErr: true, + errContains: "failed to execute template", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := htmlExporter.WriteHTML(tt.writer, tt.course) + + if tt.wantErr { + if err == nil { + t.Errorf("WriteHTML() expected error containing %q, got nil", tt.errContains) + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("WriteHTML() error = %v, want error containing %q", err, tt.errContains) + } + } else if err != nil { + t.Errorf("WriteHTML() unexpected error = %v", err) + } + }) + } +} + +// TestHTMLExporter_Export_NilCourse verifies that exporting a nil course panics. +// This documents the current behavior - nil course is not a supported input. +func TestHTMLExporter_Export_NilCourse(t *testing.T) { + htmlCleaner := services.NewHTMLCleaner() + exporter := NewHTMLExporter(htmlCleaner, nil) + + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "nil-course.html") + + defer func() { + if r := recover(); r == nil { + t.Error("Export(nil) expected panic, got none") + } + }() + + // Export nil course - should panic + _ = exporter.Export(nil, outputPath) +} + +// TestHTMLExporter_WriteHTML_NilCourse verifies that WriteHTML with nil course panics. +func TestHTMLExporter_WriteHTML_NilCourse(t *testing.T) { + htmlCleaner := services.NewHTMLCleaner() + exporter := NewHTMLExporter(htmlCleaner, nil) + htmlExporter := exporter.(*HTMLExporter) + + defer func() { + if r := recover(); r == nil { + t.Error("WriteHTML(nil) expected panic, got none") + } + }() + + var buf strings.Builder + _ = htmlExporter.WriteHTML(&buf, nil) +} + +// TestHTMLExporter_WriteHTML_SuccessWithBuffer tests WriteHTML with a buffer. +func TestHTMLExporter_WriteHTML_SuccessWithBuffer(t *testing.T) { + htmlCleaner := services.NewHTMLCleaner() + exporter := NewHTMLExporter(htmlCleaner, nil) + htmlExporter := exporter.(*HTMLExporter) + + tests := []struct { + name string + course *models.Course + contains []string + }{ + { + name: "basic course", + course: createTestCourseForHTML(), + contains: []string{ + "", + "", + "Test Course", + }, + }, + { + name: "course with special characters", + course: &models.Course{ + ShareID: "special-chars-id", + Course: models.CourseInfo{ + ID: "special-course", + Title: "Course with & \"characters\"", + Description: "

Description with & entities

", + Lessons: []models.Lesson{}, + }, + }, + contains: []string{ + "", + "Course with", + "special", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + err := htmlExporter.WriteHTML(&buf, tt.course) + if err != nil { + t.Fatalf("WriteHTML() unexpected error: %v", err) + } + + output := buf.String() + for _, want := range tt.contains { + if !strings.Contains(output, want) { + t.Errorf("WriteHTML() output missing %q", want) + } + } + }) + } +} + +// failingWriter is a test helper that fails after writing a certain number of bytes. +type failingWriter struct { + written int + failAfter int +} + +func (w *failingWriter) Write(p []byte) (n int, err error) { + if w.written >= w.failAfter { + return 0, errWriteFailed + } + w.written += len(p) + if w.written > w.failAfter { + return 0, errWriteFailed + } + return len(p), nil +} + +var errWriteFailed = &writeError{msg: "simulated write failure"} + +type writeError struct { + msg string +} + +func (e *writeError) Error() string { + return e.msg +} + // createTestCourseForHTML creates a test course for HTML export tests. func createTestCourseForHTML() *models.Course { return &models.Course{ @@ -439,7 +660,7 @@ func createTestCourseForHTML() *models.Course { // BenchmarkHTMLExporter_Export benchmarks the Export method. func BenchmarkHTMLExporter_Export(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) course := createTestCourseForHTML() tempDir := b.TempDir() @@ -455,7 +676,7 @@ func BenchmarkHTMLExporter_Export(b *testing.B) { // BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course. func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewHTMLExporter(htmlCleaner) + exporter := NewHTMLExporter(htmlCleaner, nil) // Create complex course for benchmarking course := &models.Course{ diff --git a/internal/exporters/markdown.go b/internal/exporters/markdown.go index a39a4ab..b407402 100644 --- a/internal/exporters/markdown.go +++ b/internal/exporters/markdown.go @@ -19,19 +19,23 @@ import ( type MarkdownExporter struct { // htmlCleaner is used to convert HTML content to plain text htmlCleaner *services.HTMLCleaner + // logger is used for logging warnings and errors + logger interfaces.Logger } // NewMarkdownExporter creates a new MarkdownExporter instance. -// It takes an HTMLCleaner to handle HTML content conversion. +// It takes an HTMLCleaner to handle HTML content conversion and a logger for logging. // // Parameters: // - htmlCleaner: Service for cleaning HTML content in course data +// - logger: Logger instance for warnings and errors // // Returns: // - An implementation of the Exporter interface for Markdown format -func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter { +func NewMarkdownExporter(htmlCleaner *services.HTMLCleaner, logger interfaces.Logger) interfaces.Exporter { return &MarkdownExporter{ htmlCleaner: htmlCleaner, + logger: logger, } } @@ -40,19 +44,22 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro var buf bytes.Buffer // Write course header - fmt.Fprintf(&buf, "# %s\n\n", course.Course.Title) + fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(course.Course.Title)) if course.Course.Description != "" { - fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(course.Course.Description)) + desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(course.Course.Description)) + if desc != "" { + fmt.Fprintf(&buf, "%s\n\n", desc) + } } // Add metadata buf.WriteString("## Course Information\n\n") - fmt.Fprintf(&buf, "- **Course ID**: %s\n", course.Course.ID) - fmt.Fprintf(&buf, "- **Share ID**: %s\n", course.ShareID) - fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", course.Course.NavigationMode) + fmt.Fprintf(&buf, "- **Course ID**: %s\n", strings.TrimSpace(course.Course.ID)) + fmt.Fprintf(&buf, "- **Share ID**: %s\n", strings.TrimSpace(course.ShareID)) + fmt.Fprintf(&buf, "- **Navigation Mode**: %s\n", strings.TrimSpace(course.Course.NavigationMode)) if course.Course.ExportSettings != nil { - fmt.Fprintf(&buf, "- **Export Format**: %s\n", course.Course.ExportSettings.Format) + fmt.Fprintf(&buf, "- **Export Format**: %s\n", strings.TrimSpace(course.Course.ExportSettings.Format)) } buf.WriteString("\n---\n\n") @@ -60,15 +67,18 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro lessonCounter := 0 for _, lesson := range course.Course.Lessons { if lesson.Type == lessonTypeSection { - fmt.Fprintf(&buf, "# %s\n\n", lesson.Title) + fmt.Fprintf(&buf, "# %s\n\n", strings.TrimSpace(lesson.Title)) continue } lessonCounter++ - fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, lesson.Title) + fmt.Fprintf(&buf, "## Lesson %d: %s\n\n", lessonCounter, strings.TrimSpace(lesson.Title)) if lesson.Description != "" { - fmt.Fprintf(&buf, "%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description)) + desc := strings.TrimSpace(e.htmlCleaner.CleanHTML(lesson.Description)) + if desc != "" { + fmt.Fprintf(&buf, "%s\n\n", desc) + } } // Process lesson items @@ -76,16 +86,49 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro e.processItemToMarkdown(&buf, item, 3) } - buf.WriteString("\n---\n\n") + buf.WriteString("---\n\n") } + // Clean up multiple consecutive blank lines before writing + output := cleanMarkdownWhitespace(buf.Bytes()) + // #nosec G306 - 0644 is appropriate for export files that should be readable by others - if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil { + if err := os.WriteFile(outputPath, output, 0o644); err != nil { return fmt.Errorf("failed to write markdown file: %w", err) } return nil } +// cleanMarkdownWhitespace normalizes whitespace in markdown output. +// It removes trailing whitespace from lines and collapses multiple blank lines into one. +func cleanMarkdownWhitespace(data []byte) []byte { + lines := strings.Split(string(data), "\n") + result := make([]string, 0, len(lines)) + prevBlank := false + + for _, line := range lines { + // Trim trailing whitespace from each line + trimmed := strings.TrimRight(line, " \t") + + // Collapse multiple consecutive blank lines + isBlank := trimmed == "" + if isBlank && prevBlank { + continue + } + + result = append(result, trimmed) + prevBlank = isBlank + } + + // Remove trailing blank lines + for len(result) > 0 && result[len(result)-1] == "" { + result = result[:len(result)-1] + } + + // Ensure file ends with newline + return []byte(strings.Join(result, "\n") + "\n") +} + // SupportedFormat returns "markdown". func (e *MarkdownExporter) SupportedFormat() string { return FormatMarkdown @@ -123,13 +166,13 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models. func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, headingPrefix string) { for _, subItem := range item.Items { if subItem.Heading != "" { - heading := e.htmlCleaner.CleanHTML(subItem.Heading) + heading := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Heading)) if heading != "" { fmt.Fprintf(buf, "%s %s\n\n", headingPrefix, heading) } } if subItem.Paragraph != "" { - paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph) + paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) if paragraph != "" { fmt.Fprintf(buf, "%s\n\n", paragraph) } @@ -141,7 +184,7 @@ func (e *MarkdownExporter) processTextItem(buf *bytes.Buffer, item models.Item, func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) { for _, subItem := range item.Items { if subItem.Paragraph != "" { - paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph) + paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) if paragraph != "" { fmt.Fprintf(buf, "- %s\n", paragraph) } @@ -166,8 +209,10 @@ func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models e.processImageMedia(buf, subItem.Media) } if subItem.Caption != "" { - caption := e.htmlCleaner.CleanHTML(subItem.Caption) - fmt.Fprintf(buf, "*%s*\n", caption) + caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption)) + if caption != "" { + fmt.Fprintf(buf, "*%s*\n", caption) + } } } @@ -196,8 +241,10 @@ func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item, fmt.Fprintf(buf, "**Image**: %s\n", subItem.Media.Image.OriginalURL) } if subItem.Caption != "" { - caption := e.htmlCleaner.CleanHTML(subItem.Caption) - fmt.Fprintf(buf, "*%s*\n", caption) + caption := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Caption)) + if caption != "" { + fmt.Fprintf(buf, "*%s*\n", caption) + } } } buf.WriteString("\n") @@ -215,27 +262,35 @@ func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item mod // processQuestionSubItem processes individual question items. func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) { if subItem.Title != "" { - title := e.htmlCleaner.CleanHTML(subItem.Title) - fmt.Fprintf(buf, "**Question**: %s\n\n", title) + title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) + if title != "" { + fmt.Fprintf(buf, "**Question**: %s\n\n", title) + } } e.processAnswers(buf, subItem.Answers) if subItem.Feedback != "" { - feedback := e.htmlCleaner.CleanHTML(subItem.Feedback) - fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback) + feedback := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Feedback)) + if feedback != "" { + fmt.Fprintf(buf, "\n**Feedback**: %s\n", feedback) + } } } // processAnswers processes answer choices for quiz questions. func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) { + if len(answers) == 0 { + return + } buf.WriteString("**Answers**:\n") for i, answer := range answers { correctMark := "" if answer.Correct { - correctMark = " ✓" + correctMark = " [correct]" } - fmt.Fprintf(buf, "%d. %s%s\n", i+1, answer.Title, correctMark) + title := strings.TrimSpace(answer.Title) + fmt.Fprintf(buf, "%d. %s%s\n", i+1, title, correctMark) } } @@ -244,8 +299,10 @@ func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models fmt.Fprintf(buf, "%s Interactive Content\n\n", headingPrefix) for _, subItem := range item.Items { if subItem.Title != "" { - title := e.htmlCleaner.CleanHTML(subItem.Title) - fmt.Fprintf(buf, "**%s**\n\n", title) + title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) + if title != "" { + fmt.Fprintf(buf, "**%s**\n\n", title) + } } } } @@ -269,11 +326,15 @@ func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Ite // processGenericSubItem processes sub-items for unknown types. func (e *MarkdownExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) { if subItem.Title != "" { - title := e.htmlCleaner.CleanHTML(subItem.Title) - fmt.Fprintf(buf, "**%s**\n\n", title) + title := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Title)) + if title != "" { + fmt.Fprintf(buf, "**%s**\n\n", title) + } } if subItem.Paragraph != "" { - paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph) - fmt.Fprintf(buf, "%s\n\n", paragraph) + paragraph := strings.TrimSpace(e.htmlCleaner.CleanHTML(subItem.Paragraph)) + if paragraph != "" { + fmt.Fprintf(buf, "%s\n\n", paragraph) + } } } diff --git a/internal/exporters/markdown_test.go b/internal/exporters/markdown_test.go index 84a6fdd..845a790 100644 --- a/internal/exporters/markdown_test.go +++ b/internal/exporters/markdown_test.go @@ -14,7 +14,7 @@ import ( // TestNewMarkdownExporter tests the NewMarkdownExporter constructor. func TestNewMarkdownExporter(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) if exporter == nil { t.Fatal("NewMarkdownExporter() returned nil") @@ -34,7 +34,7 @@ func TestNewMarkdownExporter(t *testing.T) { // TestMarkdownExporter_SupportedFormat tests the SupportedFormat method. func TestMarkdownExporter_SupportedFormat(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) expected := "markdown" result := exporter.SupportedFormat() @@ -47,7 +47,7 @@ func TestMarkdownExporter_SupportedFormat(t *testing.T) { // TestMarkdownExporter_Export tests the Export method. func TestMarkdownExporter_Export(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) // Create test course testCourse := createTestCourseForMarkdown() @@ -108,7 +108,7 @@ func TestMarkdownExporter_Export(t *testing.T) { // TestMarkdownExporter_Export_InvalidPath tests export with invalid output path. func TestMarkdownExporter_Export_InvalidPath(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) testCourse := createTestCourseForMarkdown() @@ -283,7 +283,7 @@ func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) { if !strings.Contains(result, "**Answers**:") { t.Error("Should contain answers heading") } - if !strings.Contains(result, "2. Paris ✓") { + if !strings.Contains(result, "2. Paris [correct]") { t.Error("Should mark correct answer") } if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") { @@ -432,7 +432,7 @@ func TestMarkdownExporter_ProcessAnswers(t *testing.T) { if !strings.Contains(result, "1. Answer 1") { t.Error("Should contain first answer") } - if !strings.Contains(result, "2. Answer 2 ✓") { + if !strings.Contains(result, "2. Answer 2 [correct]") { t.Error("Should mark correct answer") } if !strings.Contains(result, "3. Answer 3") { @@ -510,7 +510,7 @@ func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) { // TestMarkdownExporter_ComplexCourse tests export of a complex course structure. func TestMarkdownExporter_ComplexCourse(t *testing.T) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) // Create complex test course course := &models.Course{ @@ -603,7 +603,7 @@ func TestMarkdownExporter_ComplexCourse(t *testing.T) { "- Second objective", "### Knowledge Check", "**Question**: What will you learn?", - "2. Everything ✓", + "2. Everything [correct]", "**Feedback**: Great choice!", } @@ -654,7 +654,7 @@ func createTestCourseForMarkdown() *models.Course { // BenchmarkMarkdownExporter_Export benchmarks the Export method. func BenchmarkMarkdownExporter_Export(b *testing.B) { htmlCleaner := services.NewHTMLCleaner() - exporter := NewMarkdownExporter(htmlCleaner) + exporter := NewMarkdownExporter(htmlCleaner, nil) course := createTestCourseForMarkdown() // Create temporary directory diff --git a/internal/exporters/output.md b/internal/exporters/output.md deleted file mode 100644 index be78d86..0000000 --- a/internal/exporters/output.md +++ /dev/null @@ -1,12 +0,0 @@ -# Example Course - -Course description - -## Course Information - -- **Course ID**: -- **Share ID**: example-id -- **Navigation Mode**: - ---- - diff --git a/internal/models/course.go b/internal/models/course.go index e4d0756..152c236 100644 --- a/internal/models/course.go +++ b/internal/models/course.go @@ -2,6 +2,8 @@ // These structures closely match the JSON format used by Articulate Rise. package models +import "fmt" + // Course represents the top-level structure of an Articulate Rise course. // It contains metadata and the actual course content. type Course struct { @@ -53,3 +55,46 @@ type LabelSet struct { // Labels is a mapping of label keys to their customized values 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) +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go index f830c24..d51ffe3 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "errors" "reflect" "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. func compareMaps(original, unmarshaled any) bool { origMap, origOk := original.(map[string]any) diff --git a/internal/services/app.go b/internal/services/app.go index 5e6c1de..06ec364 100644 --- a/internal/services/app.go +++ b/internal/services/app.go @@ -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. -// It takes the path to the course file, the desired export format, and the output file path. -// Returns an error if loading or exporting fails. -func (a *App) ProcessCourseFromFile(filePath, format, outputPath string) error { +// It takes a context for cancellation, the path to the course file, the desired export format, +// and the output file path. Returns an error if loading or exporting fails, or if the context +// 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) if err != nil { 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. -// It takes the URI to fetch the course from, the desired export format, and the output file path. -// Returns an error if fetching or exporting fails. +// It takes a context for cancellation, the URI to fetch the course from, the desired export format, +// 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 { course, err := a.parser.FetchCourse(ctx, uri) if err != nil { 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. // 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. -func (a *App) exportCourse(course *models.Course, format, outputPath string) error { +// Returns an error if creating the exporter or exporting the course fails, or if the +// 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) if err != nil { return fmt.Errorf("failed to create exporter: %w", err) diff --git a/internal/services/app_test.go b/internal/services/app_test.go index ac5215e..db68c7e 100644 --- a/internal/services/app_test.go +++ b/internal/services/app_test.go @@ -207,7 +207,7 @@ func TestApp_ProcessCourseFromFile(t *testing.T) { tt.setupMocks(parser, factory, exporter) 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 err == nil { diff --git a/internal/services/example_test.go b/internal/services/example_test.go index 808a295..37482ec 100644 --- a/internal/services/example_test.go +++ b/internal/services/example_test.go @@ -5,7 +5,10 @@ import ( "context" "fmt" "log" + "log/slog" + "time" + "github.com/kjanat/articulate-parser/internal/config" "github.com/kjanat/articulate-parser/internal/services" ) @@ -14,8 +17,8 @@ func ExampleNewArticulateParser() { // Create a no-op logger for this example logger := services.NewNoOpLogger() - // Create parser with defaults - parser := services.NewArticulateParser(logger, "", 0) + // Create parser with defaults (nil config uses defaults) + parser := services.NewArticulateParser(logger, nil) fmt.Printf("Parser created: %T\n", parser) // Output: Parser created: *services.ArticulateParser @@ -26,11 +29,11 @@ func ExampleNewArticulateParser_custom() { logger := services.NewNoOpLogger() // Create parser with custom base URL and timeout - parser := services.NewArticulateParser( - logger, - "https://custom.articulate.com", - 60_000_000_000, // 60 seconds in nanoseconds - ) + cfg := &config.Config{ + BaseURL: "https://custom.articulate.com", + RequestTimeout: 60 * time.Second, + } + parser := services.NewArticulateParser(logger, cfg) fmt.Printf("Parser configured: %T\n", parser) // Output: Parser configured: *services.ArticulateParser @@ -39,7 +42,7 @@ func ExampleNewArticulateParser_custom() { // ExampleArticulateParser_LoadCourseFromFile demonstrates loading a course from a file. func ExampleArticulateParser_LoadCourseFromFile() { logger := services.NewNoOpLogger() - parser := services.NewArticulateParser(logger, "", 0) + parser := services.NewArticulateParser(logger, nil) // In a real scenario, you'd have an actual file // This example shows the API usage @@ -52,7 +55,7 @@ func ExampleArticulateParser_LoadCourseFromFile() { // ExampleArticulateParser_FetchCourse demonstrates fetching a course from a URI. func ExampleArticulateParser_FetchCourse() { logger := services.NewNoOpLogger() - parser := services.NewArticulateParser(logger, "", 0) + parser := services.NewArticulateParser(logger, nil) // Create a context with timeout 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. func ExampleHTMLCleaner() { cleaner := services.NewHTMLCleaner() diff --git a/internal/services/logger_factory.go b/internal/services/logger_factory.go new file mode 100644 index 0000000..aaa3bf6 --- /dev/null +++ b/internal/services/logger_factory.go @@ -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) +} diff --git a/internal/services/parser.go b/internal/services/parser.go index a6a058c..b79eb0d 100644 --- a/internal/services/parser.go +++ b/internal/services/parser.go @@ -9,17 +9,14 @@ import ( "net/url" "os" "regexp" - "time" + "github.com/kjanat/articulate-parser/internal/config" "github.com/kjanat/articulate-parser/internal/interfaces" "github.com/kjanat/articulate-parser/internal/models" ) -// Default endpoint configuration for the Articulate Rise API. -const ( - riseHost = "rise.articulate.com" - defaultBaseURL = "https://" + riseHost -) +// riseHost is the only host accepted in Articulate Rise share URIs. +const riseHost = "rise.articulate.com" // shareIDRegex is compiled once at package init for extracting share IDs from URIs. var shareIDRegex = regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`) @@ -35,23 +32,19 @@ type ArticulateParser struct { Logger interfaces.Logger } -// NewArticulateParser creates a new ArticulateParser instance. -// If baseURL is empty, uses the default Articulate Rise API URL. -// If timeout is zero, uses a 30-second timeout. -func NewArticulateParser(logger interfaces.Logger, baseURL string, timeout time.Duration) interfaces.CourseParser { +// NewArticulateParser creates a new ArticulateParser instance using the provided configuration. +// If cfg is nil, a default configuration will be used. +func NewArticulateParser(logger interfaces.Logger, cfg *config.Config) interfaces.CourseParser { if logger == nil { logger = NewNoOpLogger() } - if baseURL == "" { - baseURL = defaultBaseURL - } - if timeout == 0 { - timeout = 30 * time.Second + if cfg == nil { + cfg = config.Load() } return &ArticulateParser{ - BaseURL: baseURL, + BaseURL: cfg.BaseURL, Client: &http.Client{ - Timeout: timeout, + Timeout: cfg.RequestTimeout, }, Logger: logger, } diff --git a/internal/services/parser_bench_test.go b/internal/services/parser_bench_test.go index 74decec..09f28d5 100644 --- a/internal/services/parser_bench_test.go +++ b/internal/services/parser_bench_test.go @@ -8,7 +8,9 @@ import ( "os" "path/filepath" "testing" + "time" + "github.com/kjanat/articulate-parser/internal/config" "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) } - parser := NewArticulateParser(nil, "", 0) + cfg := &config.Config{ + BaseURL: "https://rise.articulate.com", + RequestTimeout: 30 * time.Second, + } + parser := NewArticulateParser(nil, cfg) b.ResetTimer() for b.Loop() { @@ -180,7 +186,11 @@ func BenchmarkArticulateParser_LoadCourseFromFile_Large(b *testing.B) { 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() for b.Loop() { diff --git a/internal/services/parser_fuzz_test.go b/internal/services/parser_fuzz_test.go new file mode 100644 index 0000000..29e1b83 --- /dev/null +++ b/internal/services/parser_fuzz_test.go @@ -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"}`)) + f.Add([]byte(`{"course":{"title":"Test\n\r\t\u0000Title"}}`)) + f.Add([]byte(`{"course":{"description":"

HTML content

"}}`)) + + 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("

Simple paragraph

") + f.Add("

Nested

") + f.Add("

Title

Content

") + f.Add("Bold and italic") + f.Add("Link") + f.Add("\"Image\"/") + f.Add("

") + f.Add("") + f.Add("
Cell
") + + // 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("

Unclosed paragraph") + f.Add("

Close tag first

") + f.Add("

Deep nesting") + f.Add("") + f.Add("") + f.Add("") + f.Add("") + f.Add("") + f.Add("

Attributes

") + f.Add("Whitespace in tag") + f.Add("<>empty tag") + f.Add("<<>>") + f.Add("

Event handler

") + + // Add seed corpus with special characters + f.Add("

\x00null byte

") + f.Add("

\xffhigh byte

") + f.Add("

emoji: \xf0\x9f\x98\x80

") + f.Add("

unicode: \u2603

") + f.Add("

newlines\n\r\nand\rtabs\there

") + + // 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("

" + string(make([]byte, 1000)) + "

") // 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("

normal

")) + f.Add([]byte{0x3c, 0x70, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x70, 0x3e}) //

..invalid..

+ 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 +

+ f.Add([]byte("

\xc0\xaf

")) // 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) + }) +} diff --git a/internal/services/parser_test.go b/internal/services/parser_test.go index a1c4576..abe28b0 100644 --- a/internal/services/parser_test.go +++ b/internal/services/parser_test.go @@ -11,12 +11,14 @@ import ( "testing" "time" + "github.com/kjanat/articulate-parser/internal/config" "github.com/kjanat/articulate-parser/internal/models" ) // TestNewArticulateParser tests the NewArticulateParser constructor. func TestNewArticulateParser(t *testing.T) { - parser := NewArticulateParser(nil, "", 0) + // Test with nil config (should use defaults) + parser := NewArticulateParser(nil, nil) if parser == 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) } - parser := NewArticulateParser(nil, "", 0) + cfg := &config.Config{ + BaseURL: "https://rise.articulate.com", + RequestTimeout: 30 * time.Second, + } + parser := NewArticulateParser(nil, cfg) tests := []struct { name string @@ -271,7 +277,11 @@ func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) { 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) if err == nil { diff --git a/internal/testdata/fixtures.go b/internal/testdata/fixtures.go new file mode 100644 index 0000000..5055bc5 --- /dev/null +++ b/internal/testdata/fixtures.go @@ -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: "

Sample paragraph with formatting.

", + }, + { + ID: "sub-2", + Paragraph: "

Another paragraph with emphasis.

", + }, +} + +// 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: "

A basic course for testing.

", + NavigationMode: "menu", + Lessons: []models.Lesson{ + { + ID: "section-1", + Title: "Introduction Section", + Type: "section", + }, + { + ID: "lesson-1", + Title: "First Lesson", + Type: "lesson", + Description: "

Lesson introduction text.

", + Items: []models.Item{ + TextItem("Welcome", "

Welcome to the course!

"), + }, + }, + }, + }, + } +} + +// 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: "

A comprehensive course with all item types.

", + 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: "

Introduction to the course content.

", + Icon: "book", + Items: []models.Item{ + TextItem("Welcome", "

Welcome to this comprehensive course!

"), + TextItem("Objectives", "

In this course you will learn:

"), + 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: "

Core concepts and fundamentals.

", + Items: []models.Item{ + TextItem("Key Concepts", "

Let's explore the key concepts.

"), + 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: "

Deep dive into advanced topics.

", + Items: []models.Item{ + TextItem("Advanced Section", "

This section covers advanced material.

"), + 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: "

Course wrap-up and next steps.

", + Items: []models.Item{ + TextItem("Summary", "

Thank you for completing this course!

"), + 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("

Description for lesson %d with formatting.

", i+1), + Items: []models.Item{ + TextItem( + fmt.Sprintf("Heading %d", i+1), + fmt.Sprintf("

Content for lesson %d with bold and italic text.

", 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: "

Large course for performance testing.

", + 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("

%s

", 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("

%s

", question), + Answers: answers, + Feedback: "

Thank you for your response.

", + }, + }, + } +} + +// 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("

%s content

", mediaType), + Caption: fmt.Sprintf("

Caption for %s

", 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("

%s

", 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("

%s

", front), + }, + Back: &models.CardSide{ + Description: fmt.Sprintf("

%s

", 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("
%s
", 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("

%s

", 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("

%s

", 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: "

Match the following items:

", + Answers: answers, + Feedback: "

Check your matches!

", + }, + }, + } +} + +// 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", + } +} diff --git a/internal/testdata/fixtures_test.go b/internal/testdata/fixtures_test.go new file mode 100644 index 0000000..294797c --- /dev/null +++ b/internal/testdata/fixtures_test.go @@ -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", "

Test paragraph

") + 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") + } +} diff --git a/main.go b/main.go index e66b9b5..ba9db34 100644 --- a/main.go +++ b/main.go @@ -7,11 +7,12 @@ import ( "context" "fmt" "os" + "os/signal" "strings" + "syscall" "github.com/kjanat/articulate-parser/internal/config" "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/version" ) @@ -26,20 +27,19 @@ func main() { // run contains the main application logic and returns an exit code. // This function is testable as it doesn't call os.Exit directly. 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 cfg := config.Load() // Dependency injection setup with configuration - var logger interfaces.Logger - if cfg.LogFormat == "json" { - logger = services.NewSlogLogger(cfg.LogLevel) - } else { - logger = services.NewTextLogger(cfg.LogLevel) - } + logger := services.NewLoggerFromConfig(cfg) htmlCleaner := services.NewHTMLCleaner() - parser := services.NewArticulateParser(logger, cfg.BaseURL, cfg.RequestTimeout) - exporterFactory := exporters.NewFactory(htmlCleaner) + parser := services.NewArticulateParser(logger, cfg) + exporterFactory := exporters.NewFactory(htmlCleaner, logger) app := services.NewApp(parser, exporterFactory) // Check for version flag @@ -70,9 +70,9 @@ func run(args []string) int { // Determine if source is a URI or file path if isURI(source) { - err = app.ProcessCourseFromURI(context.Background(), source, format, output) + err = app.ProcessCourseFromURI(ctx, source, format, output) } else { - err = app.ProcessCourseFromFile(source, format, output) + err = app.ProcessCourseFromFile(ctx, source, format, output) } if err != nil {