Refactors main function and enhances test suite

Refactors the main function for improved testability by extracting
the core logic into a new run function. Updates argument handling
and error reporting to use return codes instead of os.Exit.

Adds comprehensive test coverage for main functionality,
including integration tests and validation against edge cases.

Enhances README with updated code coverage and feature improvement lists.

Addresses improved maintainability and testability of the application.

Bumps version to 0.3.1
This commit is contained in:
2025-05-28 14:23:23 +02:00
parent 1b945ca2bc
commit 1c1460ff04
5 changed files with 341 additions and 27 deletions

3
.gitignore vendored
View File

@ -48,9 +48,12 @@ build/
# Test coverage files # Test coverage files
coverage.out coverage.out
coverage.txt coverage.txt
coverage.html
coverage.*
coverage coverage
*.cover *.cover
*.coverprofile *.coverprofile
main_coverage
# Other common exclusions # Other common exclusions
*.exe *.exe

View File

@ -2,6 +2,16 @@
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown, HTML, and Word documents. A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown, HTML, and Word documents.
[![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod]
[![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation]
[![Go Report Card](https://goreportcard.com/badge/github.com/kjanat/articulate-parser)][Go report]
[![Tag](https://img.shields.io/github/v/tag/kjanat/articulate-parser?sort=semver&label=Tag)][Tags] <!-- [![Release Date](https://img.shields.io/github/release-date/kjanat/articulate-parser?label=Release%20date)][Latest release] -->
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)][MIT License] <!-- [![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits] -->
[![Last commit](https://img.shields.io/github/last-commit/kjanat/articulate-parser?label=Last%20commit)][Commits]
[![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/kjanat/articulate-parser?label=Issues)][Issues]
[![CI](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/ci.yml?logo=github&label=CI)][Build]
[![Codecov](https://img.shields.io/codecov/c/gh/kjanat/articulate-parser?token=eHhaHY8nut&logo=codecov&logoColor=%23F01F7A&label=Codecov)][Codecov]
## System Architecture ## System Architecture
```mermaid ```mermaid
@ -73,16 +83,6 @@ The system follows **Clean Architecture** principles with clear separation of co
- **📤 Export Layer**: Factory pattern for format-specific exporters - **📤 Export Layer**: Factory pattern for format-specific exporters
- **📊 Data Layer**: Domain models representing course structure - **📊 Data Layer**: Domain models representing course structure
[![Go version](https://img.shields.io/github/go-mod/go-version/kjanat/articulate-parser?logo=Go&logoColor=white)][gomod]
[![Go Doc](https://godoc.org/github.com/kjanat/articulate-parser?status.svg)][Package documentation]
[![Go Report Card](https://goreportcard.com/badge/github.com/kjanat/articulate-parser)][Go report]
[![Tag](https://img.shields.io/github/v/tag/kjanat/articulate-parser?sort=semver&label=Tag)][Tags] <!-- [![Release Date](https://img.shields.io/github/release-date/kjanat/articulate-parser?label=Release%20date)][Latest release] -->
[![License](https://img.shields.io/github/license/kjanat/articulate-parser?label=License)][MIT License] <!-- [![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits] -->
[![Last commit](https://img.shields.io/github/last-commit/kjanat/articulate-parser?label=Last%20commit)][Commits]
[![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/kjanat/articulate-parser?label=Issues)][Issues]
[![CI](https://img.shields.io/github/actions/workflow/status/kjanat/articulate-parser/ci.yml?logo=github&label=CI)][Build]
[![Codecov](https://img.shields.io/codecov/c/gh/kjanat/articulate-parser?token=eHhaHY8nut&logo=codecov&logoColor=%23F01F7A&label=Codecov)][Codecov]
## Features ## Features
- Parse Articulate Rise JSON data from URLs or local files - Parse Articulate Rise JSON data from URLs or local files
@ -291,7 +291,7 @@ The parser includes error handling for:
<!-- ## Code coverage <!-- ## Code coverage
![Sunburst](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut) ![Sunburst](https://codecov.io/gh/kjanat/articulate-parser/graphs/sunburst.svg?token=eHhaHY8nut)
![Grid](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut) ![Grid](https://codecov.io/gh/kjanat/articulate-parser/graphs/tree.svg?token=eHhaHY8nut)
@ -315,12 +315,12 @@ The parser includes error handling for:
Potential improvements could include: Potential improvements could include:
- PDF export support - [ ] PDF export support
- Media file downloading - [ ] Media file downloading
- ~~HTML export with preserved styling~~**Completed** - [x] ~~HTML export with preserved styling~~
- SCORM package support - [ ] SCORM package support
- Batch processing capabilities - [ ] Batch processing capabilities
- Custom template support - [ ] Custom template support
## License ## License

View File

@ -5,7 +5,7 @@ package version
// Version information. // Version information.
var ( var (
// Version is the current version of the application. // Version is the current version of the application.
Version = "0.3.0" Version = "0.3.1"
// BuildTime is the time the binary was built. // BuildTime is the time the binary was built.
BuildTime = "unknown" BuildTime = "unknown"

26
main.go
View File

@ -16,6 +16,12 @@ import (
// It handles command-line arguments, sets up dependencies, // It handles command-line arguments, sets up dependencies,
// and coordinates the parsing and exporting of courses. // and coordinates the parsing and exporting of courses.
func main() { func main() {
os.Exit(run(os.Args))
}
// 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 {
// Dependency injection setup // Dependency injection setup
htmlCleaner := services.NewHTMLCleaner() htmlCleaner := services.NewHTMLCleaner()
parser := services.NewArticulateParser() parser := services.NewArticulateParser()
@ -23,20 +29,20 @@ func main() {
app := services.NewApp(parser, exporterFactory) app := services.NewApp(parser, exporterFactory)
// Check for required command-line arguments // Check for required command-line arguments
if len(os.Args) < 4 { if len(args) < 4 {
fmt.Printf("Usage: %s <source> <format> <output>\n", os.Args[0]) fmt.Printf("Usage: %s <source> <format> <output>\n", args[0])
fmt.Printf(" source: URI or file path to the course\n") fmt.Printf(" source: URI or file path to the course\n")
fmt.Printf(" format: export format (%s)\n", joinStrings(app.GetSupportedFormats(), ", ")) fmt.Printf(" format: export format (%s)\n", joinStrings(app.GetSupportedFormats(), ", "))
fmt.Printf(" output: output file path\n") fmt.Printf(" output: output file path\n")
fmt.Println("\nExample:") fmt.Println("\nExample:")
fmt.Printf(" %s articulate-sample.json markdown output.md\n", os.Args[0]) fmt.Printf(" %s articulate-sample.json markdown output.md\n", args[0])
fmt.Printf(" %s https://rise.articulate.com/share/xyz docx output.docx\n", os.Args[0]) fmt.Printf(" %s https://rise.articulate.com/share/xyz docx output.docx\n", args[0])
os.Exit(1) return 1
} }
source := os.Args[1] source := args[1]
format := os.Args[2] format := args[2]
output := os.Args[3] output := args[3]
var err error var err error
@ -48,10 +54,12 @@ func main() {
} }
if err != nil { if err != nil {
log.Fatalf("Error processing course: %v", err) log.Printf("Error processing course: %v", err)
return 1
} }
fmt.Printf("Successfully exported course to %s\n", output) fmt.Printf("Successfully exported course to %s\n", output)
return 0
} }
// isURI checks if a string is a URI by looking for http:// or https:// prefixes. // isURI checks if a string is a URI by looking for http:// or https:// prefixes.

View File

@ -2,6 +2,12 @@
package main package main
import ( import (
"bytes"
"fmt"
"io"
"log"
"os"
"strings"
"testing" "testing"
) )
@ -173,3 +179,300 @@ func BenchmarkJoinStrings(b *testing.B) {
joinStrings(strs, separator) joinStrings(strs, separator)
} }
} }
// TestRunWithInsufficientArgs tests the run function with insufficient command-line arguments.
func TestRunWithInsufficientArgs(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "no arguments",
args: []string{"articulate-parser"},
},
{
name: "one argument",
args: []string{"articulate-parser", "source"},
},
{
name: "two arguments",
args: []string{"articulate-parser", "source", "format"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Run the function
exitCode := run(tt.args)
// Restore stdout
w.Close()
os.Stdout = oldStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify exit code
if exitCode != 1 {
t.Errorf("Expected exit code 1, got %d", exitCode)
}
// Verify usage message is displayed
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected usage message in output, got: %s", output)
}
if !strings.Contains(output, "export format") {
t.Errorf("Expected format information in output, got: %s", output)
}
})
}
}
// TestRunWithInvalidFile tests the run function with a non-existent file.
func TestRunWithInvalidFile(t *testing.T) {
// Capture stdout and stderr
oldStdout := os.Stdout
oldStderr := os.Stderr
stdoutR, stdoutW, _ := os.Pipe()
stderrR, stderrW, _ := os.Pipe()
os.Stdout = stdoutW
os.Stderr = stderrW
// Also need to redirect log output
oldLogOutput := log.Writer()
log.SetOutput(stderrW)
// Run with non-existent file
args := []string{"articulate-parser", "nonexistent-file.json", "markdown", "output.md"}
exitCode := run(args)
// Restore stdout/stderr and log output
stdoutW.Close()
stderrW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
log.SetOutput(oldLogOutput)
// Read captured output
var stdoutBuf, stderrBuf bytes.Buffer
io.Copy(&stdoutBuf, stdoutR)
io.Copy(&stderrBuf, stderrR)
stdoutR.Close()
stderrR.Close()
// Verify exit code
if exitCode != 1 {
t.Errorf("Expected exit code 1 for non-existent file, got %d", exitCode)
}
// Should have error output
errorOutput := stderrBuf.String()
if !strings.Contains(errorOutput, "Error processing course") {
t.Errorf("Expected error message about processing course, got: %s", errorOutput)
}
}
// TestRunWithInvalidURI tests the run function with an invalid URI.
func TestRunWithInvalidURI(t *testing.T) {
// Capture stdout and stderr
oldStdout := os.Stdout
oldStderr := os.Stderr
stdoutR, stdoutW, _ := os.Pipe()
stderrR, stderrW, _ := os.Pipe()
os.Stdout = stdoutW
os.Stderr = stderrW
// Also need to redirect log output
oldLogOutput := log.Writer()
log.SetOutput(stderrW)
// Run with invalid URI (will fail because we can't actually fetch)
args := []string{"articulate-parser", "https://example.com/invalid", "markdown", "output.md"}
exitCode := run(args)
// Restore stdout/stderr and log output
stdoutW.Close()
stderrW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
log.SetOutput(oldLogOutput)
// Read captured output
var stdoutBuf, stderrBuf bytes.Buffer
io.Copy(&stdoutBuf, stdoutR)
io.Copy(&stderrBuf, stderrR)
stdoutR.Close()
stderrR.Close()
// Should fail because the URI is invalid/unreachable
if exitCode != 1 {
t.Errorf("Expected failure (exit code 1) for invalid URI, got %d", exitCode)
}
// Should have error output
errorOutput := stderrBuf.String()
if !strings.Contains(errorOutput, "Error processing course") {
t.Errorf("Expected error message about processing course, got: %s", errorOutput)
}
}
// TestRunWithValidJSONFile tests the run function with a valid JSON file.
func TestRunWithValidJSONFile(t *testing.T) {
// Create a temporary test JSON file
testContent := `{
"title": "Test Course",
"lessons": [
{
"id": "lesson1",
"title": "Test Lesson",
"blocks": [
{
"type": "text",
"id": "block1",
"data": {
"text": "Test content"
}
}
]
}
]
}`
tmpFile, err := os.CreateTemp("", "test-course-*.json")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(testContent); err != nil {
t.Fatalf("Failed to write test content: %v", err)
}
tmpFile.Close()
// Test successful run with valid file
outputFile := "test-output.md"
defer os.Remove(outputFile)
// Save original stdout
originalStdout := os.Stdout
defer func() { os.Stdout = originalStdout }()
// Capture stdout
r, w, _ := os.Pipe()
os.Stdout = w
args := []string{"articulate-parser", tmpFile.Name(), "markdown", outputFile}
exitCode := run(args)
// Close write end and restore stdout
w.Close()
os.Stdout = originalStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify successful execution
if exitCode != 0 {
t.Errorf("Expected successful execution (exit code 0), got %d", exitCode)
}
// Verify success message
expectedMsg := fmt.Sprintf("Successfully exported course to %s", outputFile)
if !strings.Contains(output, expectedMsg) {
t.Errorf("Expected success message '%s' in output, got: %s", expectedMsg, output)
}
// Verify output file was created
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
t.Errorf("Expected output file %s to be created", outputFile)
}
}
// TestRunIntegration tests the run function with different output formats using sample file.
func TestRunIntegration(t *testing.T) {
// Skip if sample file doesn't exist
if _, err := os.Stat("articulate-sample.json"); os.IsNotExist(err) {
t.Skip("Skipping integration test: articulate-sample.json not found")
}
formats := []struct {
format string
output string
}{
{"markdown", "test-output.md"},
{"html", "test-output.html"},
{"docx", "test-output.docx"},
}
for _, format := range formats {
t.Run("format_"+format.format, func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Run the function
args := []string{"articulate-parser", "articulate-sample.json", format.format, format.output}
exitCode := run(args)
// Restore stdout
w.Close()
os.Stdout = oldStdout
// Read captured output
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Clean up test file
defer os.Remove(format.output)
// Verify successful execution
if exitCode != 0 {
t.Errorf("Expected successful execution (exit code 0), got %d", exitCode)
}
// Verify success message
expectedMsg := "Successfully exported course to " + format.output
if !strings.Contains(output, expectedMsg) {
t.Errorf("Expected success message '%s' in output, got: %s", expectedMsg, output)
}
// Verify output file was created
if _, err := os.Stat(format.output); os.IsNotExist(err) {
t.Errorf("Expected output file %s to be created", format.output)
}
})
}
}
// TestMainFunction tests that the main function exists and is properly structured.
// We can't test os.Exit behavior directly, but we can verify the main function
// calls the run function correctly by testing run function behavior.
func TestMainFunction(t *testing.T) {
// Test that insufficient args return exit code 1
exitCode := run([]string{"program"})
if exitCode != 1 {
t.Errorf("Expected run to return exit code 1 for insufficient args, got %d", exitCode)
}
// Test that main function exists (this is mainly for coverage)
// The main function just calls os.Exit(run(os.Args)), which we can't test directly
// but we've tested the run function thoroughly above.
}