6 Commits

Author SHA1 Message Date
1c1460ff04 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
2025-05-28 14:23:56 +02:00
1b945ca2bc Updates version and improves diagram styling
Bumps application version to 0.3.0 for new features or fixes.
Enhances README diagram styling for better readability in both
light and dark GitHub themes by adjusting colors and adding
text color contrast.
2025-05-28 13:26:33 +02:00
fb343f9a23 Merge pull request #2
Merge pull request #2: Add HTML export functionality and fix build system cross-compilation issues
2025-05-28 13:23:45 +02:00
ce5b5c20bb Enhances README and script improvements
Updates README to include HTML format, architecture overview,
and comprehensive feature descriptions. Improves build scripts
by adding cleanup of environment variables and validating Go
installation, reducing cross-platform build issues.

Fixes README inconsistencies and improves script reliability.
2025-05-28 13:17:08 +02:00
cc11d2fd84 feat: Add HTML export functionality and GitHub workflow
- Implement HTMLExporter with professional styling and embedded CSS
- Add comprehensive test suite for HTML export functionality
- Update factory to support HTML format ('html' and 'htm')
- Add autofix.ci GitHub workflow for code formatting
- Support all content types: text, lists, quizzes, multimedia, etc.
- Include proper HTML escaping for security
- Add benchmark tests for performance validation
2025-05-28 13:00:27 +02:00
b01260e765 Add comprehensive unit tests for services and main package
- Implement tests for the app service, including course processing from file and URI.
- Create mock implementations for CourseParser and Exporter to facilitate testing.
- Add tests for HTML cleaner service to validate HTML content cleaning functionality.
- Develop tests for the parser service, covering course fetching and loading from files.
- Introduce tests for utility functions in the main package, ensuring URI validation and string joining.
- Include benchmarks for performance evaluation of key functions.
2025-05-25 15:46:10 +02:00
24 changed files with 6333 additions and 211 deletions

25
.github/workflows/autofix.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: autofix.ci
on:
pull_request:
push:
branches: [ "master" ]
permissions:
contents: read
jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
# goimports works like gofmt, but also fixes imports.
# see https://pkg.go.dev/golang.org/x/tools/cmd/goimports
- run: go install golang.org/x/tools/cmd/goimports@latest
- run: goimports -w .
# of course we can also do just this instead:
# - run: gofmt -w .
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@ -16,7 +16,11 @@ jobs:
contents: write
strategy:
matrix:
go: [1.21.x, 1.22.x, 1.23.x, 1.24.x]
go:
- 1.21.x
- 1.22.x
- 1.23.x
- 1.24.x
steps:
- uses: actions/checkout@v4
@ -37,20 +41,171 @@ jobs:
- name: Build
run: go build -v ./...
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Run tests with enhanced reporting
id: test
run: |
echo "## 🔧 Test Environment" >> $GITHUB_STEP_SUMMARY
echo "- **Go Version:** ${{ matrix.go }}" >> $GITHUB_STEP_SUMMARY
echo "- **OS:** ubuntu-latest" >> $GITHUB_STEP_SUMMARY
echo "- **Timestamp:** $(date -u)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Running tests with coverage..."
go test -v -race -coverprofile=coverage.out ./... 2>&1 | tee test-output.log
# Extract test results for summary
TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" test-output.log || echo "0")
PASSED_TESTS=$(grep -c "--- PASS:" test-output.log || echo "0")
FAILED_TESTS=$(grep -c "--- FAIL:" test-output.log || echo "0")
SKIPPED_TESTS=$(grep -c "--- SKIP:" test-output.log || echo "0")
# Generate test summary
echo "## 🧪 Test Results (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Tests | $TOTAL_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Passed | ✅ $PASSED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Failed | ❌ $FAILED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Skipped | ⏭️ $SKIPPED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Status | $([ $TEST_STATUS -eq 0 ] && echo "✅ PASSED" || echo "❌ FAILED") |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Add package breakdown
echo "### 📦 Package Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Status |" >> $GITHUB_STEP_SUMMARY
echo "|---------|--------|" >> $GITHUB_STEP_SUMMARY
# Extract package results
grep "^ok\|^FAIL" test-output.log | while read line; do
if [[ $line == ok* ]]; then
pkg=$(echo $line | awk '{print $2}')
echo "| $pkg | ✅ PASS |" >> $GITHUB_STEP_SUMMARY
elif [[ $line == FAIL* ]]; then
pkg=$(echo $line | awk '{print $2}')
echo "| $pkg | ❌ FAIL |" >> $GITHUB_STEP_SUMMARY
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
# Add detailed results if tests failed
if [ $TEST_STATUS -ne 0 ]; then
echo "### ❌ Failed Tests Details" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
grep -A 10 "--- FAIL:" test-output.log | head -100 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# Set outputs for other steps
echo "test-status=$TEST_STATUS" >> $GITHUB_OUTPUT
echo "total-tests=$TOTAL_TESTS" >> $GITHUB_OUTPUT
echo "passed-tests=$PASSED_TESTS" >> $GITHUB_OUTPUT
echo "failed-tests=$FAILED_TESTS" >> $GITHUB_OUTPUT
# Exit with the original test status
exit $TEST_STATUS
- name: Generate coverage report
if: always()
run: |
if [ -f coverage.out ]; then
go tool cover -html=coverage.out -o coverage.html
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "## 📊 Code Coverage (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total Coverage: $COVERAGE**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Add coverage by package
echo "### 📋 Coverage by Package" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Package | Coverage |" >> $GITHUB_STEP_SUMMARY
echo "|---------|----------|" >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.out | grep -v total | while read line; do
if [[ $line == *".go:"* ]]; then
pkg=$(echo $line | awk '{print $1}' | cut -d'/' -f1-3)
coverage=$(echo $line | awk '{print $3}')
echo "| $pkg | $coverage |" >> $GITHUB_STEP_SUMMARY
fi
done | sort -u
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "## ⚠️ Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "No coverage file generated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-results-go-${{ matrix.go }}
path: |
test-output.log
coverage.out
coverage.html
retention-days: 7
- name: Run go vet
run: go vet ./...
run: |
echo "## 🔍 Static Analysis (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
VET_OUTPUT=$(go vet ./... 2>&1 || echo "")
VET_STATUS=$?
if [ $VET_STATUS -eq 0 ]; then
echo "✅ **go vet:** No issues found" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **go vet:** Issues found" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "$VET_OUTPUT" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
exit $VET_STATUS
- name: Run go fmt
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
gofmt -s -l .
FMT_OUTPUT=$(gofmt -s -l . 2>&1 || echo "")
if [ -z "$FMT_OUTPUT" ]; then
echo "✅ **go fmt:** All files properly formatted" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **go fmt:** Files need formatting" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "$FMT_OUTPUT" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Job Summary
if: always()
run: |
echo "## 📋 Job Summary (Go ${{ matrix.go }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Dependencies | ✅ Success |" >> $GITHUB_STEP_SUMMARY
echo "| Build | ✅ Success |" >> $GITHUB_STEP_SUMMARY
echo "| Tests | ${{ steps.test.outcome == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Coverage | ${{ job.status == 'success' && '✅ Generated' || '⚠️ Partial' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Static Analysis | ${{ job.status == 'success' && '✅ Clean' || '❌ Issues' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Code Formatting | ${{ job.status == 'success' && '✅ Clean' || '❌ Issues' }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
@ -81,9 +236,6 @@ jobs:
fail-on-severity: moderate
comment-summary-in-pr: always
# # Use comma-separated names to pass list arguments:
# deny-licenses: LGPL-2.0, BSD-2-Clause
release:
name: Release
runs-on: ubuntu-latest
@ -103,7 +255,26 @@ jobs:
check-latest: true
- name: Run tests
run: go test -v ./...
run: |
echo "## 🚀 Release Tests" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
go test -v ./... 2>&1 | tee release-test-output.log
TEST_STATUS=$?
TOTAL_TESTS=$(grep -c "=== RUN" release-test-output.log || echo "0")
PASSED_TESTS=$(grep -c "--- PASS:" release-test-output.log || echo "0")
FAILED_TESTS=$(grep -c "--- FAIL:" release-test-output.log || echo "0")
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Tests | $TOTAL_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Passed | ✅ $PASSED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Failed | ❌ $FAILED_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Status | $([ $TEST_STATUS -eq 0 ] && echo "✅ PASSED" || echo "❌ FAILED") |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
exit $TEST_STATUS
- name: Install UPX
run: |
@ -112,6 +283,9 @@ jobs:
- name: Build binaries
run: |
echo "## 🔨 Build Process" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Set the build time environment variable
BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
@ -121,6 +295,12 @@ jobs:
# Display help information for the build script
./scripts/build.sh --help
echo "**Build Configuration:**" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "- Build Time: $BUILD_TIME" >> $GITHUB_STEP_SUMMARY
echo "- Git Commit: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Build for all platforms
./scripts/build.sh \
--verbose \
@ -128,13 +308,18 @@ jobs:
- name: Compress binaries with UPX
run: |
echo "## 📦 Binary Compression" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Compressing binaries with UPX..."
cd build/
# Get original sizes
echo "Original sizes:"
ls -lah
echo ""
echo "**Original sizes:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -lah >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Compress all binaries except Darwin (macOS) binaries as UPX doesn't work well with recent macOS versions
for binary in articulate-parser-*; do
@ -148,16 +333,16 @@ jobs:
fi
done
echo ""
echo "Final sizes:"
ls -lah
echo "**Final sizes:**" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -lah >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- name: Upload a Build Artifact
uses: actions/upload-artifact@v4.6.2
with:
# Artifact name
name: build-artifacts # optional, default is artifact
# A file, directory or wildcard pattern that describes what to upload
name: build-artifacts
path: build/
if-no-files-found: ignore
retention-days: 1

30
.gitignore vendored
View File

@ -26,11 +26,41 @@ go.work
# End of https://www.toptal.com/developers/gitignore/api/go
# Shit
.github/TODO
# Local test files
output/
outputs/
articulate-sample.json
test-output.*
go-os-arch-matrix.csv
test_godocx.go
test_input.json
# Build artifacts
build/
# Old workflows
.github/workflows/ci-old.yml
.github/workflows/ci-enhanced.yml
# Test coverage files
coverage.out
coverage.txt
coverage.html
coverage.*
coverage
*.cover
*.coverprofile
main_coverage
# Other common exclusions
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
/tmp/

197
README.md
View File

@ -1,23 +1,93 @@
# Articulate Rise Parser
A Go-based parser that converts Articulate Rise e-learning content to various formats including Markdown 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)](LICENSE)
[![Commit activity](https://img.shields.io/github/commit-activity/m/kjanat/articulate-parser?label=Commit%20activity)][Commits]
[![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
```mermaid
flowchart TD
%% User Input
CLI[Command Line Interface<br/>main.go] --> APP{App Service<br/>services/app.go}
%% Core Application Logic
APP --> |"ProcessCourseFromURI"| PARSER[Course Parser<br/>services/parser.go]
APP --> |"ProcessCourseFromFile"| PARSER
APP --> |"exportCourse"| FACTORY[Exporter Factory<br/>exporters/factory.go]
%% Data Sources
PARSER --> |"FetchCourse"| API[Articulate Rise API<br/>rise.articulate.com]
PARSER --> |"LoadCourseFromFile"| FILE[Local JSON File<br/>*.json]
%% Data Models
API --> MODELS[Data Models<br/>models/course.go]
FILE --> MODELS
MODELS --> |Course, Lesson, Item| APP
%% Export Factory Pattern
FACTORY --> |"CreateExporter"| MARKDOWN[Markdown Exporter<br/>exporters/markdown.go]
FACTORY --> |"CreateExporter"| HTML[HTML Exporter<br/>exporters/html.go]
FACTORY --> |"CreateExporter"| DOCX[DOCX Exporter<br/>exporters/docx.go]
%% HTML Cleaning Service
CLEANER[HTML Cleaner<br/>services/html_cleaner.go] --> MARKDOWN
CLEANER --> HTML
CLEANER --> DOCX
%% Output Files
MARKDOWN --> |"Export"| MD_OUT[Markdown Files<br/>*.md]
HTML --> |"Export"| HTML_OUT[HTML Files<br/>*.html]
DOCX --> |"Export"| DOCX_OUT[Word Documents<br/>*.docx]
%% Interfaces (Contracts)
IPARSER[CourseParser Interface<br/>interfaces/parser.go] -.-> PARSER
IEXPORTER[Exporter Interface<br/>interfaces/exporter.go] -.-> MARKDOWN
IEXPORTER -.-> HTML
IEXPORTER -.-> DOCX
IFACTORY[ExporterFactory Interface<br/>interfaces/exporter.go] -.-> FACTORY
%% Styling - Colors that work in both light and dark GitHub themes
classDef userInput fill:#dbeafe,stroke:#1e40af,stroke-width:2px,color:#1e40af
classDef coreLogic fill:#ede9fe,stroke:#6d28d9,stroke-width:2px,color:#6d28d9
classDef dataSource fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#059669
classDef exporter fill:#fed7aa,stroke:#ea580c,stroke-width:2px,color:#ea580c
classDef output fill:#fce7f3,stroke:#be185d,stroke-width:2px,color:#be185d
classDef interface fill:#ecfdf5,stroke:#16a34a,stroke-width:1px,stroke-dasharray: 5 5,color:#16a34a
classDef service fill:#cffafe,stroke:#0891b2,stroke-width:2px,color:#0891b2
class CLI userInput
class APP,FACTORY coreLogic
class API,FILE,MODELS dataSource
class MARKDOWN,HTML,DOCX exporter
class MD_OUT,HTML_OUT,DOCX_OUT output
class IPARSER,IEXPORTER,IFACTORY interface
class PARSER,CLEANER service
```
### Architecture Overview
The system follows **Clean Architecture** principles with clear separation of concerns:
- **🎯 Entry Point**: Command-line interface handles user input and coordinates operations
- **🏗️ Application Layer**: Core business logic with dependency injection
- **📋 Interface Layer**: Contracts defining behavior without implementation details
- **🔧 Service Layer**: Concrete implementations of parsing and utility services
- **📤 Export Layer**: Factory pattern for format-specific exporters
- **📊 Data Layer**: Domain models representing course structure
## Features
- Parse Articulate Rise JSON data from URLs or local files
- Export to Markdown (.md) format
- Export to HTML (.html) format with professional styling
- Export to Word Document (.docx) format
- Support for various content types:
- Text content with headings and paragraphs
@ -29,20 +99,50 @@ A Go-based parser that converts Articulate Rise e-learning content to various fo
## Installation
1. Ensure you have Go 1.21 or later installed
2. Clone or download the parser code
3. Initialize the Go module:
### Prerequisites
- Go, I don't know the version, but I use go1.24.2 right now, and it works, see the [CI][Build] workflow where it is tested.
### Install from source
```bash
go mod init articulate-parser
go mod tidy
git clone https://github.com/kjanat/articulate-parser.git
cd articulate-parser
go mod download
go build -o articulate-parser main.go
```
### Or install directly
```bash
go install github.com/kjanat/articulate-parser@latest
```
## Dependencies
The parser uses the following external library:
- `github.com/unidoc/unioffice` - For creating Word documents
- `github.com/fumiama/go-docx` - For creating Word documents (MIT license)
## Testing
Run the test suite:
```bash
go test ./...
```
Run tests with coverage:
```bash
go test -v -race -coverprofile=coverage.out ./...
```
View coverage report:
```bash
go tool cover -html=coverage.out
```
## Usage
@ -54,9 +154,11 @@ go run main.go <input_uri_or_file> <output_format> [output_path]
#### Parameters
- `input_uri_or_file`: Either an Articulate Rise share URL or path to a local JSON file
- `output_format`: `md` for Markdown or `docx` for Word Document
- `output_path`: Optional. If not provided, files are saved to `./output/` directory
| Parameter | Description | Default |
| ------------------- | ---------------------------------------------------------------- | --------------- |
| `input_uri_or_file` | Either an Articulate Rise share URL or path to a local JSON file | None (required) |
| `output_format` | `md` for Markdown, `html` for HTML, or `docx` for Word Document | None (required) |
| `output_path` | Path where output file will be saved. | `./output/` |
#### Examples
@ -72,10 +174,16 @@ go run main.go "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviD
go run main.go "articulate-sample.json" docx "my-course.docx"
```
3. **Parse from local file and export to Markdown:**
3. **Parse from local file and export to HTML:**
```bash
go run main.go "C:\Users\kjana\Projects\articulate-parser\articulate-sample.json" md
go run main.go "articulate-sample.json" html "output.html"
```
4. **Parse from local file and export to Markdown:**
```bash
go run main.go "articulate-sample.json" md "output.md"
```
### Building the Executable
@ -92,9 +200,29 @@ Then run:
./articulate-parser input.json md output.md
```
## Development
### Code Quality
The project maintains high code quality standards:
- Cyclomatic complexity ≤ 15 (checked with [gocyclo](https://github.com/fzipp/gocyclo))
- Race condition detection enabled
- Comprehensive test coverage
- Code formatting with `gofmt`
- Static analysis with `go vet`
### Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests: `go test ./...`
5. Submit a pull request
## Output Formats
### Markdown (.md)
### Markdown (`.md`)
- Hierarchical structure with proper heading levels
- Clean text content with HTML tags removed
@ -103,7 +231,16 @@ Then run:
- Media references included
- Course metadata at the top
### Word Document (.docx)
### HTML (`.html`)
- Professional styling with embedded CSS
- Interactive and visually appealing layout
- Proper HTML structure with semantic elements
- Responsive design for different screen sizes
- All content types beautifully formatted
- Maintains course hierarchy and organization
### Word Document (`.docx`)
- Professional document formatting
- Bold headings and proper typography
@ -154,7 +291,7 @@ The parser includes error handling for:
<!-- ## 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)
@ -167,16 +304,23 @@ The parser includes error handling for:
- Styling and visual formatting is not preserved
- Assessment logic and interactivity is lost in static exports
## Performance
- Lightweight with minimal dependencies
- Fast JSON parsing and export
- Memory efficient processing
- No external license requirements
## Future Enhancements
Potential improvements could include:
- PDF export support
- Media file downloading
- HTML export with preserved styling
- SCORM package support
- Batch processing capabilities
- Custom template support
- [ ] PDF export support
- [ ] Media file downloading
- [x] ~~HTML export with preserved styling~~
- [ ] SCORM package support
- [ ] Batch processing capabilities
- [ ] Custom template support
## License
@ -188,6 +332,7 @@ This is a utility tool for educational content conversion. Please ensure you hav
[Go report]: https://goreportcard.com/report/github.com/kjanat/articulate-parser
[gomod]: go.mod
[Issues]: https://github.com/kjanat/articulate-parser/issues
[Latest release]: https://github.com/kjanat/articulate-parser/releases/latest
<!-- [Latest release]: https://github.com/kjanat/articulate-parser/releases/latest -->
[MIT License]: LICENSE
[Package documentation]: https://godoc.org/github.com/kjanat/articulate-parser
[Tags]: https://github.com/kjanat/articulate-parser/tags

9
go.mod
View File

@ -1,7 +1,10 @@
module github.com/kjanat/articulate-parser
go 1.21
go 1.23.0
require github.com/unidoc/unioffice v1.39.0
require github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b
require github.com/richardlehane/msoleps v1.0.4 // indirect
require (
github.com/fumiama/imgsz v0.0.4 // indirect
golang.org/x/image v0.27.0 // indirect
)

12
go.sum
View File

@ -1,6 +1,6 @@
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/unidoc/unioffice v1.39.0 h1:Wo5zvrzCqhyK/1Zi5dg8a5F5+NRftIMZPnFPYwruLto=
github.com/unidoc/unioffice v1.39.0/go.mod h1:Axz6ltIZZTUUyHoEnPe4Mb3VmsN4TRHT5iZCGZ1rgnU=
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b h1:/mxSugRc4SgN7XgBtT19dAJ7cAXLTbPmlJLJE4JjRkE=
github.com/fumiama/go-docx v0.0.0-20250506085032-0c30fd09304b/go.mod h1:ssRF0IaB1hCcKIObp3FkZOsjTcAHpgii70JelNb4H8M=
github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI=
github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=

View File

@ -4,17 +4,18 @@ package exporters
import (
"fmt"
"os"
"strings"
"github.com/fumiama/go-docx"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
"github.com/unidoc/unioffice/document"
)
// DocxExporter implements the Exporter interface for DOCX format.
// It converts Articulate Rise course data into a Microsoft Word document
// using the unioffice/document package.
// using the go-docx package.
type DocxExporter struct {
// htmlCleaner is used to convert HTML content to plain text
htmlCleaner *services.HTMLCleaner
@ -45,21 +46,17 @@ func NewDocxExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
// Returns:
// - An error if creating or saving the document fails
func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
doc := document.New()
doc := docx.New()
// Add title
titlePara := doc.AddParagraph()
titleRun := titlePara.AddRun()
titleRun.AddText(course.Course.Title)
titleRun.Properties().SetBold(true)
titleRun.Properties().SetSize(16)
titlePara.AddText(course.Course.Title).Size("32").Bold()
// Add description if available
if course.Course.Description != "" {
descPara := doc.AddParagraph()
descRun := descPara.AddRun()
cleanDesc := e.htmlCleaner.CleanHTML(course.Course.Description)
descRun.AddText(cleanDesc)
descPara.AddText(cleanDesc)
}
// Add each lesson
@ -72,7 +69,20 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
outputPath = outputPath + ".docx"
}
return doc.SaveToFile(outputPath)
// Create the file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
// Save the document
_, err = doc.WriteTo(file)
if err != nil {
return fmt.Errorf("failed to save document: %w", err)
}
return nil
}
// exportLesson adds a lesson to the document with appropriate formatting.
@ -81,20 +91,16 @@ func (e *DocxExporter) Export(course *models.Course, outputPath string) error {
// Parameters:
// - doc: The Word document being created
// - lesson: The lesson data model to export
func (e *DocxExporter) exportLesson(doc *document.Document, lesson *models.Lesson) {
func (e *DocxExporter) exportLesson(doc *docx.Docx, lesson *models.Lesson) {
// Add lesson title
lessonPara := doc.AddParagraph()
lessonRun := lessonPara.AddRun()
lessonRun.AddText(fmt.Sprintf("Lesson: %s", lesson.Title))
lessonRun.Properties().SetBold(true)
lessonRun.Properties().SetSize(14)
lessonPara.AddText(fmt.Sprintf("Lesson: %s", lesson.Title)).Size("28").Bold()
// Add lesson description if available
if lesson.Description != "" {
descPara := doc.AddParagraph()
descRun := descPara.AddRun()
cleanDesc := e.htmlCleaner.CleanHTML(lesson.Description)
descRun.AddText(cleanDesc)
descPara.AddText(cleanDesc)
}
// Add each item in the lesson
@ -109,14 +115,11 @@ func (e *DocxExporter) exportLesson(doc *document.Document, lesson *models.Lesso
// Parameters:
// - doc: The Word document being created
// - item: The item data model to export
func (e *DocxExporter) exportItem(doc *document.Document, item *models.Item) {
func (e *DocxExporter) exportItem(doc *docx.Docx, item *models.Item) {
// Add item type as heading
if item.Type != "" {
itemPara := doc.AddParagraph()
itemRun := itemPara.AddRun()
itemRun.AddText(strings.Title(item.Type))
itemRun.Properties().SetBold(true)
itemRun.Properties().SetSize(12)
itemPara.AddText(strings.Title(item.Type)).Size("24").Bold()
}
// Add sub-items
@ -132,58 +135,48 @@ func (e *DocxExporter) exportItem(doc *document.Document, item *models.Item) {
// Parameters:
// - doc: The Word document being created
// - subItem: The sub-item data model to export
func (e *DocxExporter) exportSubItem(doc *document.Document, subItem *models.SubItem) {
func (e *DocxExporter) exportSubItem(doc *docx.Docx, subItem *models.SubItem) {
// Add title if available
if subItem.Title != "" {
subItemPara := doc.AddParagraph()
subItemRun := subItemPara.AddRun()
subItemRun.AddText(" " + subItem.Title) // Indented
subItemRun.Properties().SetBold(true)
subItemPara.AddText(" " + subItem.Title).Bold() // Indented
}
// Add heading if available
if subItem.Heading != "" {
headingPara := doc.AddParagraph()
headingRun := headingPara.AddRun()
cleanHeading := e.htmlCleaner.CleanHTML(subItem.Heading)
headingRun.AddText(" " + cleanHeading) // Indented
headingRun.Properties().SetBold(true)
headingPara.AddText(" " + cleanHeading).Bold() // Indented
}
// Add paragraph content if available
if subItem.Paragraph != "" {
contentPara := doc.AddParagraph()
contentRun := contentPara.AddRun()
cleanContent := e.htmlCleaner.CleanHTML(subItem.Paragraph)
contentRun.AddText(" " + cleanContent) // Indented
contentPara.AddText(" " + cleanContent) // Indented
}
// Add answers if this is a question
if len(subItem.Answers) > 0 {
answersPara := doc.AddParagraph()
answersRun := answersPara.AddRun()
answersRun.AddText(" Answers:")
answersRun.Properties().SetBold(true)
answersPara.AddText(" Answers:").Bold()
for i, answer := range subItem.Answers {
answerPara := doc.AddParagraph()
answerRun := answerPara.AddRun()
prefix := fmt.Sprintf(" %d. ", i+1)
if answer.Correct {
prefix += "✓ "
}
cleanAnswer := e.htmlCleaner.CleanHTML(answer.Title)
answerRun.AddText(prefix + cleanAnswer)
answerPara.AddText(prefix + cleanAnswer)
}
}
// Add feedback if available
if subItem.Feedback != "" {
feedbackPara := doc.AddParagraph()
feedbackRun := feedbackPara.AddRun()
cleanFeedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
feedbackRun.AddText(" Feedback: " + cleanFeedback)
feedbackRun.Properties().SetItalic(true)
feedbackPara.AddText(" Feedback: " + cleanFeedback).Italic()
}
}

View File

@ -0,0 +1,679 @@
// Package exporters_test provides tests for the docx exporter.
package exporters
import (
"os"
"path/filepath"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewDocxExporter tests the NewDocxExporter constructor.
func TestNewDocxExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewDocxExporter() returned nil")
}
// Type assertion to check internal structure
docxExporter, ok := exporter.(*DocxExporter)
if !ok {
t.Fatal("NewDocxExporter() returned wrong type")
}
if docxExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestDocxExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestDocxExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
expected := "docx"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestDocxExporter_Export tests the Export method.
func TestDocxExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForDocx()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.docx")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Verify file has some content (basic check)
fileInfo, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("Failed to get file info: %v", err)
}
if fileInfo.Size() == 0 {
t.Error("Output file is empty")
}
}
// TestDocxExporter_Export_AddDocxExtension tests that the .docx extension is added automatically.
func TestDocxExporter_Export_AddDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Create temporary directory and file without .docx extension
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course")
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created with .docx extension
expectedPath := outputPath + ".docx"
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Fatal("Output file with .docx extension was not created")
}
}
// TestDocxExporter_Export_InvalidPath tests export with invalid output path.
func TestDocxExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Try to write to invalid path
invalidPath := "/invalid/path/that/does/not/exist/file.docx"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Fatal("Expected error for invalid path, got nil")
}
}
// TestDocxExporter_ExportLesson tests the exportLesson method indirectly through Export.
func TestDocxExporter_ExportLesson(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with specific lesson content
course := &models.Course{
ShareID: "test-id",
Course: models.CourseInfo{
ID: "test-course",
Title: "Test Course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description with <strong>bold</strong> text.</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Title: "Test Item Title",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "lesson-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created successfully
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ExportItem tests the exportItem method indirectly through Export.
func TestDocxExporter_ExportItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with different item types
course := &models.Course{
ShareID: "test-id",
Course: models.CourseInfo{
ID: "test-course",
Title: "Item Test Course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Item Types Lesson",
Type: "lesson",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Title: "Text Item",
Paragraph: "<p>Text content</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>List item 1</p>"},
{Paragraph: "<p>List item 2</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the answer?</p>",
Answers: []models.Answer{
{Title: "Option A", Correct: false},
{Title: "Option B", Correct: true},
},
Feedback: "<p>Correct answer explanation</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "items-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created successfully
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ExportSubItem tests the exportSubItem method indirectly through Export.
func TestDocxExporter_ExportSubItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with sub-item containing all possible fields
course := &models.Course{
ShareID: "test-id",
Course: models.CourseInfo{
ID: "test-course",
Title: "SubItem Test Course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "SubItem Test Lesson",
Type: "lesson",
Items: []models.Item{
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>Question Title</p>",
Heading: "<h3>Question Heading</h3>",
Paragraph: "<p>Question description with <em>emphasis</em>.</p>",
Answers: []models.Answer{
{Title: "Wrong answer", Correct: false},
{Title: "Correct answer", Correct: true},
{Title: "Another wrong answer", Correct: false},
},
Feedback: "<p>Feedback with <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "subitem-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created successfully
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ComplexCourse tests export of a complex course structure.
func TestDocxExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description with <em>formatting</em>.</p>",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course with <code>code</code> and <a href='#'>links</a>.</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our comprehensive course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>Learn advanced concepts</p>"},
{Paragraph: "<p>Practice with real examples</p>"},
{Paragraph: "<p>Apply knowledge in projects</p>"},
},
},
{
Type: "multimedia",
Items: []models.SubItem{
{
Title: "<p>Video Introduction</p>",
Caption: "<p>Watch this introductory video</p>",
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/intro.mp4",
Duration: 300,
},
},
},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn in this course?</p>",
Answers: []models.Answer{
{Title: "Basic concepts only", Correct: false},
{Title: "Advanced concepts and practical application", Correct: true},
{Title: "Theory without practice", Correct: false},
},
Feedback: "<p>Excellent! This course covers both theory and practice.</p>",
},
},
},
{
Type: "image",
Items: []models.SubItem{
{
Caption: "<p>Course overview diagram</p>",
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/overview.png",
},
},
},
},
},
{
Type: "interactive",
Items: []models.SubItem{
{
Title: "<p>Interactive Exercise</p>",
},
},
},
},
},
{
ID: "lesson-2",
Title: "Advanced Topics",
Type: "lesson",
Items: []models.Item{
{
Type: "divider",
},
{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Custom Content</p>",
Paragraph: "<p>This is custom content type</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.docx")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created and has reasonable size
fileInfo, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("Failed to get file info: %v", err)
}
if fileInfo.Size() < 1000 {
t.Error("Output file seems too small for complex course content")
}
}
// TestDocxExporter_EmptyCourse tests export of an empty course.
func TestDocxExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
ShareID: "empty-id",
Course: models.CourseInfo{
ID: "empty-course",
Title: "Empty Course",
Lessons: []models.Lesson{},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "empty-course.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_HTMLCleaning tests that HTML content is properly cleaned.
func TestDocxExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create course with HTML content that needs cleaning
course := &models.Course{
ShareID: "html-test-id",
Course: models.CourseInfo{
ID: "html-test-course",
Title: "HTML Cleaning Test",
Description: "<p>Description with <script>alert('xss')</script> and <b>bold</b> text.</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<div>Lesson description with <span style='color:red'>styled</span> content.</div>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Heading with <em>emphasis</em> and &amp; entities</h1>",
Paragraph: "<p>Paragraph with &lt;code&gt; entities and <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "html-cleaning-test.docx")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created (basic check that HTML cleaning didn't break export)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
}
// TestDocxExporter_ExistingDocxExtension tests that existing .docx extension is preserved.
func TestDocxExporter_ExistingDocxExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Use path that already has .docx extension
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.docx")
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created at the exact path (no double extension)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created at expected path")
}
// Ensure no double extension was created
doubleExtensionPath := outputPath + ".docx"
if _, err := os.Stat(doubleExtensionPath); err == nil {
t.Error("Double .docx extension file should not exist")
}
}
// TestDocxExporter_CaseInsensitiveExtension tests that extension checking is case-insensitive.
func TestDocxExporter_CaseInsensitiveExtension(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
testCourse := createTestCourseForDocx()
// Test various case combinations
testCases := []string{
"test-course.DOCX",
"test-course.Docx",
"test-course.DocX",
}
for i, testCase := range testCases {
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, testCase)
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed for case %d (%s): %v", i, testCase, err)
}
// Check that file was created at the exact path (no additional extension)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatalf("Output file was not created at expected path for case %d (%s)", i, testCase)
}
}
}
// createTestCourseForDocx creates a test course for DOCX export testing.
func createTestCourseForDocx() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "<p>Test course description with <strong>formatting</strong>.</p>",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Test Heading</h2>",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First list item</p>"},
{Paragraph: "<p>Second list item</p>"},
},
},
},
},
},
},
}
}
// BenchmarkDocxExporter_Export benchmarks the Export method.
func BenchmarkDocxExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
course := createTestCourseForDocx()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.docx")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkDocxExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkDocxExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewDocxExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
ShareID: "benchmark-id",
Course: models.CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Description: "<p>Complex course for performance testing</p>",
Lessons: make([]models.Lesson, 10), // 10 lessons
},
}
// Fill with test data
for i := 0; i < 10; i++ {
lesson := models.Lesson{
ID: "lesson-" + string(rune(i)),
Title: "Lesson " + string(rune(i)),
Type: "lesson",
Items: make([]models.Item, 5), // 5 items per lesson
}
for j := 0; j < 5; j++ {
item := models.Item{
Type: "text",
Items: make([]models.SubItem, 3), // 3 sub-items per item
}
for k := 0; k < 3; k++ {
item.Items[k] = models.SubItem{
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
}
}
lesson.Items[j] = item
}
course.Course.Lessons[i] = lesson
}
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-complex.docx")
_ = exporter.Export(course, outputPath)
os.Remove(outputPath)
}
}

View File

@ -48,6 +48,8 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
return NewMarkdownExporter(f.htmlCleaner), nil
case "docx", "word":
return NewDocxExporter(f.htmlCleaner), nil
case "html", "htm":
return NewHTMLExporter(f.htmlCleaner), nil
default:
return nil, fmt.Errorf("unsupported export format: %s", format)
}
@ -59,5 +61,5 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
// Returns:
// - A string slice containing all supported format names
func (f *Factory) GetSupportedFormats() []string {
return []string{"markdown", "md", "docx", "word"}
return []string{"markdown", "md", "docx", "word", "html", "htm"}
}

View File

@ -0,0 +1,478 @@
// Package exporters_test provides tests for the exporter factory.
package exporters
import (
"reflect"
"sort"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewFactory tests the NewFactory constructor.
func TestNewFactory(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
if factory == nil {
t.Fatal("NewFactory() returned nil")
}
// Type assertion to check internal structure
factoryImpl, ok := factory.(*Factory)
if !ok {
t.Fatal("NewFactory() returned wrong type")
}
if factoryImpl.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestFactory_CreateExporter tests the CreateExporter method for all supported formats.
func TestFactory_CreateExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
testCases := []struct {
name string
format string
expectedType string
expectedFormat string
shouldError bool
}{
{
name: "markdown format",
format: "markdown",
expectedType: "*exporters.MarkdownExporter",
expectedFormat: "markdown",
shouldError: false,
},
{
name: "md format alias",
format: "md",
expectedType: "*exporters.MarkdownExporter",
expectedFormat: "markdown",
shouldError: false,
},
{
name: "docx format",
format: "docx",
expectedType: "*exporters.DocxExporter",
expectedFormat: "docx",
shouldError: false,
},
{
name: "word format alias",
format: "word",
expectedType: "*exporters.DocxExporter",
expectedFormat: "docx",
shouldError: false,
},
{
name: "html format",
format: "html",
expectedType: "*exporters.HTMLExporter",
expectedFormat: "html",
shouldError: false,
},
{
name: "htm format alias",
format: "htm",
expectedType: "*exporters.HTMLExporter",
expectedFormat: "html",
shouldError: false,
},
{
name: "unsupported format",
format: "pdf",
shouldError: true,
},
{
name: "empty format",
format: "",
shouldError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
exporter, err := factory.CreateExporter(tc.format)
if tc.shouldError {
if err == nil {
t.Errorf("Expected error for format '%s', but got nil", tc.format)
}
if exporter != nil {
t.Errorf("Expected nil exporter for unsupported format '%s'", tc.format)
}
return
}
if err != nil {
t.Fatalf("Unexpected error creating exporter for format '%s': %v", tc.format, err)
}
if exporter == nil {
t.Fatalf("CreateExporter returned nil for supported format '%s'", tc.format)
}
// Check type
exporterType := reflect.TypeOf(exporter).String()
if exporterType != tc.expectedType {
t.Errorf("Expected exporter type '%s' for format '%s', got '%s'", tc.expectedType, tc.format, exporterType)
}
// Check supported format
supportedFormat := exporter.GetSupportedFormat()
if supportedFormat != tc.expectedFormat {
t.Errorf("Expected supported format '%s' for format '%s', got '%s'", tc.expectedFormat, tc.format, supportedFormat)
}
})
}
}
// TestFactory_CreateExporter_CaseInsensitive tests that format strings are case-insensitive.
func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
testCases := []struct {
format string
expectedFormat string
}{
{"MARKDOWN", "markdown"},
{"Markdown", "markdown"},
{"MarkDown", "markdown"},
{"MD", "markdown"},
{"Md", "markdown"},
{"DOCX", "docx"},
{"Docx", "docx"},
{"DocX", "docx"},
{"WORD", "docx"},
{"Word", "docx"},
{"WoRd", "docx"},
{"HTML", "html"},
{"Html", "html"},
{"HtMl", "html"},
{"HTM", "html"},
{"Htm", "html"},
{"HtM", "html"},
}
for _, tc := range testCases {
t.Run(tc.format, func(t *testing.T) {
exporter, err := factory.CreateExporter(tc.format)
if err != nil {
t.Fatalf("Unexpected error for format '%s': %v", tc.format, err)
}
if exporter == nil {
t.Fatalf("CreateExporter returned nil for format '%s'", tc.format)
}
supportedFormat := exporter.GetSupportedFormat()
if supportedFormat != tc.expectedFormat {
t.Errorf("Expected supported format '%s' for format '%s', got '%s'", tc.expectedFormat, tc.format, supportedFormat)
}
})
}
}
// TestFactory_CreateExporter_ErrorMessages tests error messages for unsupported formats.
func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
testCases := []string{
"pdf",
"txt",
"json",
"xml",
"unknown",
"123",
"markdown-invalid",
}
for _, format := range testCases {
t.Run(format, func(t *testing.T) {
exporter, err := factory.CreateExporter(format)
if err == nil {
t.Errorf("Expected error for unsupported format '%s', got nil", format)
}
if exporter != nil {
t.Errorf("Expected nil exporter for unsupported format '%s', got %v", format, exporter)
}
// Check error message contains the format
if err != nil && !strings.Contains(err.Error(), format) {
t.Errorf("Error message should contain the unsupported format '%s', got: %s", format, err.Error())
}
// Check error message has expected prefix
if err != nil && !strings.Contains(err.Error(), "unsupported export format") {
t.Errorf("Error message should contain 'unsupported export format', got: %s", err.Error())
}
})
}
}
// TestFactory_GetSupportedFormats tests the GetSupportedFormats method.
func TestFactory_GetSupportedFormats(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
formats := factory.GetSupportedFormats()
if formats == nil {
t.Fatal("GetSupportedFormats() returned nil")
}
expected := []string{"markdown", "md", "docx", "word", "html", "htm"}
// Sort both slices for comparison
sort.Strings(formats)
sort.Strings(expected)
if !reflect.DeepEqual(formats, expected) {
t.Errorf("Expected formats %v, got %v", expected, formats)
}
// Verify all returned formats can create exporters
for _, format := range formats {
exporter, err := factory.CreateExporter(format)
if err != nil {
t.Errorf("Format '%s' from GetSupportedFormats() should be creatable, got error: %v", format, err)
}
if exporter == nil {
t.Errorf("Format '%s' from GetSupportedFormats() should create non-nil exporter", format)
}
}
}
// TestFactory_GetSupportedFormats_Immutable tests that the returned slice is safe to modify.
func TestFactory_GetSupportedFormats_Immutable(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Get formats twice
formats1 := factory.GetSupportedFormats()
formats2 := factory.GetSupportedFormats()
// Modify first slice
if len(formats1) > 0 {
formats1[0] = "modified"
}
// Check that second call returns unmodified data
if len(formats2) > 0 && formats2[0] == "modified" {
t.Error("GetSupportedFormats() should return independent slices")
}
// Verify original functionality still works
formats3 := factory.GetSupportedFormats()
if len(formats3) == 0 {
t.Error("GetSupportedFormats() should still return formats after modification")
}
}
// TestFactory_ExporterTypes tests that created exporters are of correct types.
func TestFactory_ExporterTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Test markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
if err != nil {
t.Fatalf("Failed to create markdown exporter: %v", err)
}
if _, ok := markdownExporter.(*MarkdownExporter); !ok {
t.Error("Markdown exporter should be of type *MarkdownExporter")
}
// Test docx exporter
docxExporter, err := factory.CreateExporter("docx")
if err != nil {
t.Fatalf("Failed to create docx exporter: %v", err)
}
if _, ok := docxExporter.(*DocxExporter); !ok {
t.Error("DOCX exporter should be of type *DocxExporter")
}
}
// TestFactory_HTMLCleanerPropagation tests that HTMLCleaner is properly passed to exporters.
func TestFactory_HTMLCleanerPropagation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Test with markdown exporter
markdownExporter, err := factory.CreateExporter("markdown")
if err != nil {
t.Fatalf("Failed to create markdown exporter: %v", err)
}
markdownImpl, ok := markdownExporter.(*MarkdownExporter)
if !ok {
t.Fatal("Failed to cast to MarkdownExporter")
}
if markdownImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to MarkdownExporter")
}
// Test with docx exporter
docxExporter, err := factory.CreateExporter("docx")
if err != nil {
t.Fatalf("Failed to create docx exporter: %v", err)
}
docxImpl, ok := docxExporter.(*DocxExporter)
if !ok {
t.Fatal("Failed to cast to DocxExporter")
}
if docxImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to DocxExporter")
}
// Test with html exporter
htmlExporter, err := factory.CreateExporter("html")
if err != nil {
t.Fatalf("Failed to create html exporter: %v", err)
}
htmlImpl, ok := htmlExporter.(*HTMLExporter)
if !ok {
t.Fatal("Failed to cast to HTMLExporter")
}
if htmlImpl.htmlCleaner == nil {
t.Error("HTMLCleaner should be propagated to HTMLExporter")
}
}
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
func TestFactory_MultipleExporterCreation(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Create multiple markdown exporters
exporter1, err := factory.CreateExporter("markdown")
if err != nil {
t.Fatalf("Failed to create first markdown exporter: %v", err)
}
exporter2, err := factory.CreateExporter("md")
if err != nil {
t.Fatalf("Failed to create second markdown exporter: %v", err)
}
// They should be different instances
if exporter1 == exporter2 {
t.Error("Factory should create independent exporter instances")
}
// But both should be MarkdownExporter type
if _, ok := exporter1.(*MarkdownExporter); !ok {
t.Error("First exporter should be MarkdownExporter")
}
if _, ok := exporter2.(*MarkdownExporter); !ok {
t.Error("Second exporter should be MarkdownExporter")
}
}
// TestFactory_WithNilHTMLCleaner tests factory behavior with nil HTMLCleaner.
func TestFactory_WithNilHTMLCleaner(t *testing.T) {
// This tests edge case - should not panic but behavior may vary
defer func() {
if r := recover(); r != nil {
t.Errorf("Factory should handle nil HTMLCleaner gracefully, but panicked: %v", r)
}
}()
factory := NewFactory(nil)
if factory == nil {
t.Fatal("NewFactory(nil) returned nil")
}
// Try to create an exporter - this might fail or succeed depending on implementation
_, err := factory.CreateExporter("markdown")
// We don't assert on the error since nil HTMLCleaner handling is implementation-dependent
// The important thing is that it doesn't panic
_ = err
}
// TestFactory_FormatNormalization tests that format strings are properly normalized.
func TestFactory_FormatNormalization(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
// Test formats with extra whitespace
testCases := []struct {
input string
expected string
}{
{"markdown", "markdown"},
{"MARKDOWN", "markdown"},
{"Markdown", "markdown"},
{"docx", "docx"},
{"DOCX", "docx"},
{"Docx", "docx"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
exporter, err := factory.CreateExporter(tc.input)
if err != nil {
t.Fatalf("Failed to create exporter for '%s': %v", tc.input, err)
}
format := exporter.GetSupportedFormat()
if format != tc.expected {
t.Errorf("Expected format '%s' for input '%s', got '%s'", tc.expected, tc.input, format)
}
})
}
}
// BenchmarkFactory_CreateExporter benchmarks the CreateExporter method.
func BenchmarkFactory_CreateExporter(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = factory.CreateExporter("markdown")
}
}
// BenchmarkFactory_CreateExporter_Docx benchmarks creating DOCX exporters.
func BenchmarkFactory_CreateExporter_Docx(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = factory.CreateExporter("docx")
}
}
// BenchmarkFactory_GetSupportedFormats benchmarks the GetSupportedFormats method.
func BenchmarkFactory_GetSupportedFormats(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
factory := NewFactory(htmlCleaner)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = factory.GetSupportedFormats()
}
}

476
internal/exporters/html.go Normal file
View File

@ -0,0 +1,476 @@
// Package exporters provides implementations of the Exporter interface
// for converting Articulate Rise courses into various file formats.
package exporters
import (
"bytes"
"fmt"
"html"
"os"
"strings"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// HTMLExporter implements the Exporter interface for HTML format.
// It converts Articulate Rise course data into a structured HTML document.
type HTMLExporter struct {
// htmlCleaner is used to convert HTML content to plain text when needed
htmlCleaner *services.HTMLCleaner
}
// NewHTMLExporter creates a new HTMLExporter instance.
// It takes an HTMLCleaner to handle HTML content conversion when plain text is needed.
//
// Parameters:
// - htmlCleaner: Service for cleaning HTML content in course data
//
// Returns:
// - An implementation of the Exporter interface for HTML format
func NewHTMLExporter(htmlCleaner *services.HTMLCleaner) interfaces.Exporter {
return &HTMLExporter{
htmlCleaner: htmlCleaner,
}
}
// Export exports a course to HTML format.
// It generates a structured HTML document from the course data
// and writes it to the specified output path.
//
// Parameters:
// - course: The course data model to export
// - outputPath: The file path where the HTML content will be written
//
// Returns:
// - An error if writing to the output file fails
func (e *HTMLExporter) Export(course *models.Course, outputPath string) error {
var buf bytes.Buffer
// Write HTML document structure
buf.WriteString("<!DOCTYPE html>\n")
buf.WriteString("<html lang=\"en\">\n")
buf.WriteString("<head>\n")
buf.WriteString(" <meta charset=\"UTF-8\">\n")
buf.WriteString(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
buf.WriteString(fmt.Sprintf(" <title>%s</title>\n", html.EscapeString(course.Course.Title)))
buf.WriteString(" <style>\n")
buf.WriteString(e.getDefaultCSS())
buf.WriteString(" </style>\n")
buf.WriteString("</head>\n")
buf.WriteString("<body>\n")
// Write course header
buf.WriteString(fmt.Sprintf(" <header>\n <h1>%s</h1>\n", html.EscapeString(course.Course.Title)))
if course.Course.Description != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"course-description\">%s</div>\n", course.Course.Description))
}
buf.WriteString(" </header>\n\n")
// Add metadata section
buf.WriteString(" <section class=\"course-info\">\n")
buf.WriteString(" <h2>Course Information</h2>\n")
buf.WriteString(" <ul>\n")
buf.WriteString(fmt.Sprintf(" <li><strong>Course ID:</strong> %s</li>\n", html.EscapeString(course.Course.ID)))
buf.WriteString(fmt.Sprintf(" <li><strong>Share ID:</strong> %s</li>\n", html.EscapeString(course.ShareID)))
buf.WriteString(fmt.Sprintf(" <li><strong>Navigation Mode:</strong> %s</li>\n", html.EscapeString(course.Course.NavigationMode)))
if course.Course.ExportSettings != nil {
buf.WriteString(fmt.Sprintf(" <li><strong>Export Format:</strong> %s</li>\n", html.EscapeString(course.Course.ExportSettings.Format)))
}
buf.WriteString(" </ul>\n")
buf.WriteString(" </section>\n\n")
// Process lessons
lessonCounter := 0
for _, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf(" <section class=\"course-section\">\n <h2>%s</h2>\n </section>\n\n", html.EscapeString(lesson.Title)))
continue
}
lessonCounter++
buf.WriteString(fmt.Sprintf(" <section class=\"lesson\">\n <h3>Lesson %d: %s</h3>\n", lessonCounter, html.EscapeString(lesson.Title)))
if lesson.Description != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"lesson-description\">%s</div>\n", lesson.Description))
}
// Process lesson items
for _, item := range lesson.Items {
e.processItemToHTML(&buf, item)
}
buf.WriteString(" </section>\n\n")
}
buf.WriteString("</body>\n")
buf.WriteString("</html>\n")
return os.WriteFile(outputPath, buf.Bytes(), 0644)
}
// GetSupportedFormat returns the format name this exporter supports
// It indicates the file format that the HTMLExporter can generate.
//
// Returns:
// - A string representing the supported format ("html")
func (e *HTMLExporter) GetSupportedFormat() string {
return "html"
}
// getDefaultCSS returns basic CSS styling for the HTML document
func (e *HTMLExporter) getDefaultCSS() string {
return `
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;
}
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);
}
header h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 300;
}
.course-description {
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);
}
.course-info h2 {
margin-top: 0;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.course-info ul {
list-style: none;
padding: 0;
}
.course-info li {
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);
}
.course-section h2 {
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;
}
.lesson h3 {
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;
}
.item {
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;
}
.text-item {
background: #f0fff4;
border-left: 3px solid #48bb78;
}
.list-item {
background: #fffaf0;
border-left: 3px solid #ed8936;
}
.knowledge-check {
background: #e6fffa;
border-left: 3px solid #38b2ac;
}
.multimedia-item {
background: #faf5ff;
border-left: 3px solid #9f7aea;
}
.interactive-item {
background: #fff5f5;
border-left: 3px solid #f56565;
}
.unknown-item {
background: #f7fafc;
border-left: 3px solid #a0aec0;
}
.answers {
margin: 1rem 0;
}
.answers h5 {
margin: 0.5rem 0;
color: #4a5568;
}
.answers ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.answers li {
margin: 0.3rem 0;
padding: 0.3rem;
}
.correct-answer {
background: #c6f6d5;
border-radius: 3px;
font-weight: bold;
}
.correct-answer::after {
content: " ✓";
color: #38a169;
}
.feedback {
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;
}
.media-info strong {
color: #4a5568;
}
hr {
border: none;
height: 2px;
background: linear-gradient(to right, #667eea, #764ba2);
margin: 2rem 0;
border-radius: 1px;
}
ul {
padding-left: 1.5rem;
}
li {
margin: 0.5rem 0;
}
`
}
// processItemToHTML converts a course item into HTML format
// and appends it to the provided buffer. It handles different item types
// with appropriate HTML formatting.
//
// Parameters:
// - buf: The buffer to write the HTML content to
// - item: The course item to process
func (e *HTMLExporter) processItemToHTML(buf *bytes.Buffer, item models.Item) {
switch strings.ToLower(item.Type) {
case "text":
e.processTextItem(buf, item)
case "list":
e.processListItem(buf, item)
case "knowledgecheck":
e.processKnowledgeCheckItem(buf, item)
case "multimedia":
e.processMultimediaItem(buf, item)
case "image":
e.processImageItem(buf, item)
case "interactive":
e.processInteractiveItem(buf, item)
case "divider":
e.processDividerItem(buf)
default:
e.processUnknownItem(buf, item)
}
}
// processTextItem handles text content with headings and paragraphs
func (e *HTMLExporter) processTextItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item text-item\">\n")
buf.WriteString(" <h4>Text Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Heading != "" {
buf.WriteString(fmt.Sprintf(" <h5>%s</h5>\n", subItem.Heading))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
buf.WriteString(" </div>\n\n")
}
// processListItem handles list content
func (e *HTMLExporter) processListItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item list-item\">\n")
buf.WriteString(" <h4>List</h4>\n")
buf.WriteString(" <ul>\n")
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
cleanText := e.htmlCleaner.CleanHTML(subItem.Paragraph)
buf.WriteString(fmt.Sprintf(" <li>%s</li>\n", html.EscapeString(cleanText)))
}
}
buf.WriteString(" </ul>\n")
buf.WriteString(" </div>\n\n")
}
// processKnowledgeCheckItem handles quiz questions and answers
func (e *HTMLExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item knowledge-check\">\n")
buf.WriteString(" <h4>Knowledge Check</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>Question:</strong> %s</p>\n", subItem.Title))
}
if len(subItem.Answers) > 0 {
e.processAnswers(buf, subItem.Answers)
}
if subItem.Feedback != "" {
buf.WriteString(fmt.Sprintf(" <div class=\"feedback\"><strong>Feedback:</strong> %s</div>\n", subItem.Feedback))
}
}
buf.WriteString(" </div>\n\n")
}
// processMultimediaItem handles multimedia content like videos
func (e *HTMLExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item multimedia-item\">\n")
buf.WriteString(" <h4>Media Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <h5>%s</h5>\n", subItem.Title))
}
if subItem.Media != nil {
if subItem.Media.Video != nil {
buf.WriteString(" <div class=\"media-info\">\n")
buf.WriteString(fmt.Sprintf(" <p><strong>Video:</strong> %s</p>\n", html.EscapeString(subItem.Media.Video.OriginalUrl)))
if subItem.Media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf(" <p><strong>Duration:</strong> %d seconds</p>\n", subItem.Media.Video.Duration))
}
buf.WriteString(" </div>\n")
}
}
if subItem.Caption != "" {
buf.WriteString(fmt.Sprintf(" <div><em>%s</em></div>\n", subItem.Caption))
}
}
buf.WriteString(" </div>\n\n")
}
// processImageItem handles image content
func (e *HTMLExporter) processImageItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item multimedia-item\">\n")
buf.WriteString(" <h4>Image</h4>\n")
for _, subItem := range item.Items {
if subItem.Media != nil && subItem.Media.Image != nil {
buf.WriteString(" <div class=\"media-info\">\n")
buf.WriteString(fmt.Sprintf(" <p><strong>Image:</strong> %s</p>\n", html.EscapeString(subItem.Media.Image.OriginalUrl)))
buf.WriteString(" </div>\n")
}
if subItem.Caption != "" {
buf.WriteString(fmt.Sprintf(" <div><em>%s</em></div>\n", subItem.Caption))
}
}
buf.WriteString(" </div>\n\n")
}
// processInteractiveItem handles interactive content
func (e *HTMLExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item) {
buf.WriteString(" <div class=\"item interactive-item\">\n")
buf.WriteString(" <h4>Interactive Content</h4>\n")
for _, subItem := range item.Items {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>%s</strong></p>\n", subItem.Title))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
buf.WriteString(" </div>\n\n")
}
// processDividerItem handles divider elements
func (e *HTMLExporter) processDividerItem(buf *bytes.Buffer) {
buf.WriteString(" <hr>\n\n")
}
// processUnknownItem handles unknown or unsupported item types
func (e *HTMLExporter) processUnknownItem(buf *bytes.Buffer, item models.Item) {
if len(item.Items) > 0 {
buf.WriteString(" <div class=\"item unknown-item\">\n")
buf.WriteString(fmt.Sprintf(" <h4>%s Content</h4>\n", strings.Title(item.Type)))
for _, subItem := range item.Items {
e.processGenericSubItem(buf, subItem)
}
buf.WriteString(" </div>\n\n")
}
}
// processGenericSubItem processes sub-items for unknown types
func (e *HTMLExporter) processGenericSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
buf.WriteString(fmt.Sprintf(" <p><strong>%s</strong></p>\n", subItem.Title))
}
if subItem.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" <div>%s</div>\n", subItem.Paragraph))
}
}
// processAnswers processes answer choices for quiz questions
func (e *HTMLExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
buf.WriteString(" <div class=\"answers\">\n")
buf.WriteString(" <h5>Answers:</h5>\n")
buf.WriteString(" <ol>\n")
for _, answer := range answers {
cssClass := ""
if answer.Correct {
cssClass = " class=\"correct-answer\""
}
buf.WriteString(fmt.Sprintf(" <li%s>%s</li>\n", cssClass, html.EscapeString(answer.Title)))
}
buf.WriteString(" </ol>\n")
buf.WriteString(" </div>\n")
}

View File

@ -0,0 +1,927 @@
// Package exporters_test provides tests for the html exporter.
package exporters
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewHTMLExporter tests the NewHTMLExporter constructor.
func TestNewHTMLExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewHTMLExporter() returned nil")
}
// Type assertion to check internal structure
htmlExporter, ok := exporter.(*HTMLExporter)
if !ok {
t.Fatal("NewHTMLExporter() returned wrong type")
}
if htmlExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestHTMLExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestHTMLExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
expected := "html"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestHTMLExporter_Export tests the Export method.
func TestHTMLExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForHTML()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.html")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify HTML structure
if !strings.Contains(contentStr, "<!DOCTYPE html>") {
t.Error("Output should contain HTML doctype")
}
if !strings.Contains(contentStr, "<html lang=\"en\">") {
t.Error("Output should contain HTML tag with lang attribute")
}
if !strings.Contains(contentStr, "<title>Test Course</title>") {
t.Error("Output should contain course title in head")
}
// Verify main course title
if !strings.Contains(contentStr, "<h1>Test Course</h1>") {
t.Error("Output should contain course title as main heading")
}
// Verify course information section
if !strings.Contains(contentStr, "Course Information") {
t.Error("Output should contain course information section")
}
// Verify course metadata
if !strings.Contains(contentStr, "Course ID") {
t.Error("Output should contain course ID")
}
if !strings.Contains(contentStr, "Share ID") {
t.Error("Output should contain share ID")
}
// Verify lesson content
if !strings.Contains(contentStr, "Lesson 1: Test Lesson") {
t.Error("Output should contain lesson heading")
}
// Verify CSS is included
if !strings.Contains(contentStr, "<style>") {
t.Error("Output should contain CSS styles")
}
if !strings.Contains(contentStr, "font-family") {
t.Error("Output should contain CSS font-family")
}
}
// TestHTMLExporter_Export_InvalidPath tests export with invalid output path.
func TestHTMLExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
testCourse := createTestCourseForHTML()
// Try to export to invalid path (non-existent directory)
invalidPath := "/non/existent/path/test.html"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Error("Expected error for invalid output path, but got nil")
}
}
// TestHTMLExporter_ProcessTextItem tests the processTextItem method.
func TestHTMLExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Test Heading</h1>",
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
},
{
Paragraph: "<p>Another paragraph.</p>",
},
},
}
exporter.processTextItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "text-item") {
t.Error("Should contain text-item CSS class")
}
if !strings.Contains(result, "Text Content") {
t.Error("Should contain text content heading")
}
if !strings.Contains(result, "<h1>Test Heading</h1>") {
t.Error("Should preserve HTML heading")
}
if !strings.Contains(result, "<strong>bold</strong>") {
t.Error("Should preserve HTML formatting in paragraph")
}
}
// TestHTMLExporter_ProcessListItem tests the processListItem method.
func TestHTMLExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First item</p>"},
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
{Paragraph: "<p>Third item</p>"},
},
}
exporter.processListItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "list-item") {
t.Error("Should contain list-item CSS class")
}
if !strings.Contains(result, "<ul>") {
t.Error("Should contain unordered list")
}
if !strings.Contains(result, "<li>First item</li>") {
t.Error("Should contain first list item")
}
if !strings.Contains(result, "<li>Second item with emphasis</li>") {
t.Error("Should contain second list item with cleaned HTML")
}
if !strings.Contains(result, "<li>Third item</li>") {
t.Error("Should contain third list item")
}
}
// TestHTMLExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestHTMLExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the correct answer?</p>",
Answers: []models.Answer{
{Title: "Wrong answer", Correct: false},
{Title: "Correct answer", Correct: true},
{Title: "Another wrong answer", Correct: false},
},
Feedback: "<p>Great job! This is the feedback.</p>",
},
},
}
exporter.processKnowledgeCheckItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "knowledge-check") {
t.Error("Should contain knowledge-check CSS class")
}
if !strings.Contains(result, "Knowledge Check") {
t.Error("Should contain knowledge check heading")
}
if !strings.Contains(result, "What is the correct answer?") {
t.Error("Should contain question text")
}
if !strings.Contains(result, "Wrong answer") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "correct-answer") {
t.Error("Should mark correct answer with CSS class")
}
if !strings.Contains(result, "Feedback") {
t.Error("Should contain feedback section")
}
}
// TestHTMLExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestHTMLExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "multimedia",
Items: []models.SubItem{
{
Title: "<p>Video Title</p>",
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 120,
},
},
Caption: "<p>Video caption</p>",
},
},
}
exporter.processMultimediaItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "multimedia-item") {
t.Error("Should contain multimedia-item CSS class")
}
if !strings.Contains(result, "Media Content") {
t.Error("Should contain media content heading")
}
if !strings.Contains(result, "Video Title") {
t.Error("Should contain video title")
}
if !strings.Contains(result, "https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "120 seconds") {
t.Error("Should contain video duration")
}
if !strings.Contains(result, "Video caption") {
t.Error("Should contain video caption")
}
}
// TestHTMLExporter_ProcessImageItem tests the processImageItem method.
func TestHTMLExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "image",
Items: []models.SubItem{
{
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.png",
},
},
Caption: "<p>Image caption</p>",
},
},
}
exporter.processImageItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "multimedia-item") {
t.Error("Should contain multimedia-item CSS class")
}
if !strings.Contains(result, "Image") {
t.Error("Should contain image heading")
}
if !strings.Contains(result, "https://example.com/image.png") {
t.Error("Should contain image URL")
}
if !strings.Contains(result, "Image caption") {
t.Error("Should contain image caption")
}
}
// TestHTMLExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestHTMLExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "interactive",
Items: []models.SubItem{
{
Title: "<p>Interactive element title</p>",
Paragraph: "<p>Interactive content description</p>",
},
},
}
exporter.processInteractiveItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "interactive-item") {
t.Error("Should contain interactive-item CSS class")
}
if !strings.Contains(result, "Interactive Content") {
t.Error("Should contain interactive content heading")
}
if !strings.Contains(result, "Interactive element title") {
t.Error("Should contain interactive element title")
}
if !strings.Contains(result, "Interactive content description") {
t.Error("Should contain interactive content description")
}
}
// TestHTMLExporter_ProcessDividerItem tests the processDividerItem method.
func TestHTMLExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
exporter.processDividerItem(&buf)
result := buf.String()
expected := " <hr>\n\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestHTMLExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestHTMLExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Unknown item title</p>",
Paragraph: "<p>Unknown item content</p>",
},
},
}
exporter.processUnknownItem(&buf, item)
result := buf.String()
if !strings.Contains(result, "unknown-item") {
t.Error("Should contain unknown-item CSS class")
}
if !strings.Contains(result, "Unknown Content") {
t.Error("Should contain unknown content heading")
}
if !strings.Contains(result, "Unknown item title") {
t.Error("Should contain unknown item title")
}
if !strings.Contains(result, "Unknown item content") {
t.Error("Should contain unknown item content")
}
}
// TestHTMLExporter_ProcessAnswers tests the processAnswers method.
func TestHTMLExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
answers := []models.Answer{
{Title: "Answer 1", Correct: false},
{Title: "Answer 2", Correct: true},
{Title: "Answer 3", Correct: false},
}
exporter.processAnswers(&buf, answers)
result := buf.String()
if !strings.Contains(result, "answers") {
t.Error("Should contain answers CSS class")
}
if !strings.Contains(result, "<h5>Answers:</h5>") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "<ol>") {
t.Error("Should contain ordered list")
}
if !strings.Contains(result, "<li>Answer 1</li>") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "correct-answer") {
t.Error("Should mark correct answer with CSS class")
}
if !strings.Contains(result, "<li class=\"correct-answer\">Answer 2</li>") {
t.Error("Should mark correct answer properly")
}
if !strings.Contains(result, "<li>Answer 3</li>") {
t.Error("Should contain third answer")
}
}
// TestHTMLExporter_ProcessItemToHTML_AllTypes tests all item types.
func TestHTMLExporter_ProcessItemToHTML_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
tests := []struct {
name string
itemType string
expectedText string
}{
{
name: "text item",
itemType: "text",
expectedText: "Text Content",
},
{
name: "list item",
itemType: "list",
expectedText: "List",
},
{
name: "knowledge check item",
itemType: "knowledgeCheck",
expectedText: "Knowledge Check",
},
{
name: "multimedia item",
itemType: "multimedia",
expectedText: "Media Content",
},
{
name: "image item",
itemType: "image",
expectedText: "Image",
},
{
name: "interactive item",
itemType: "interactive",
expectedText: "Interactive Content",
},
{
name: "divider item",
itemType: "divider",
expectedText: "<hr>",
},
{
name: "unknown item",
itemType: "unknown",
expectedText: "Unknown Content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
item := models.Item{
Type: tt.itemType,
Items: []models.SubItem{
{Title: "Test title", Paragraph: "Test content"},
},
}
// Handle empty unknown items
if tt.itemType == "unknown" && tt.expectedText == "" {
item.Items = []models.SubItem{}
}
exporter.processItemToHTML(&buf, item)
result := buf.String()
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
t.Errorf("Expected content to contain: %q\nGot: %q", tt.expectedText, result)
}
})
}
}
// TestHTMLExporter_ComplexCourse tests export of a complex course structure.
func TestHTMLExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description.</p>",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Format: "scorm",
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First objective</p>"},
{Paragraph: "<p>Second objective</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn?</p>",
Answers: []models.Answer{
{Title: "Nothing", Correct: false},
{Title: "Everything", Correct: true},
},
Feedback: "<p>Great choice!</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.html")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify various elements are present
checks := []string{
"<title>Complex Test Course</title>",
"<h1>Complex Test Course</h1>",
"This is a <strong>complex</strong> course description.",
"Course Information",
"complex-course",
"complex-test-id",
"menu",
"scorm",
"Course Section",
"Lesson 1: Introduction Lesson",
"Introduction to the course",
"<h2>Welcome</h2>",
"Welcome to our course!",
"First objective",
"Second objective",
"Knowledge Check",
"What will you learn?",
"Nothing",
"Everything",
"correct-answer",
"Great choice!",
}
for _, check := range checks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain: %q", check)
}
}
// Verify HTML structure
structureChecks := []string{
"<!DOCTYPE html>",
"<html lang=\"en\">",
"<head>",
"<body>",
"</html>",
"<style>",
"font-family",
}
for _, check := range structureChecks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain HTML structure element: %q", check)
}
}
}
// TestHTMLExporter_EmptyCourse tests export of an empty course.
func TestHTMLExporter_EmptyCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create minimal course
course := &models.Course{
ShareID: "empty-id",
Course: models.CourseInfo{
ID: "empty-course",
Title: "Empty Course",
Lessons: []models.Lesson{},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "empty-course.html")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify basic structure
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify basic HTML structure even for empty course
if !strings.Contains(contentStr, "<!DOCTYPE html>") {
t.Error("Output should contain HTML doctype")
}
if !strings.Contains(contentStr, "<title>Empty Course</title>") {
t.Error("Output should contain course title")
}
if !strings.Contains(contentStr, "<h1>Empty Course</h1>") {
t.Error("Output should contain course heading")
}
}
// TestHTMLExporter_HTMLCleaning tests that HTML content is properly handled.
func TestHTMLExporter_HTMLCleaning(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create course with HTML content that needs cleaning in some places
course := &models.Course{
ShareID: "html-test-id",
Course: models.CourseInfo{
ID: "html-test-course",
Title: "HTML Test Course",
Description: "<p>Description with <script>alert('xss')</script> and <b>bold</b> text.</p>",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<div>Lesson description with <span style='color:red'>styled</span> content.</div>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Heading with <em>emphasis</em> and &amp; entities</h1>",
Paragraph: "<p>Paragraph with &lt;code&gt; entities and <strong>formatting</strong>.</p>",
},
},
},
},
},
},
},
}
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "html-test.html")
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify file was created (basic check that HTML handling didn't break export)
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read content and verify some HTML is preserved (descriptions, headings, paragraphs)
// while list items are cleaned for safety
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// HTML should be preserved in some places
if !strings.Contains(contentStr, "<b>bold</b>") {
t.Error("Should preserve HTML formatting in descriptions")
}
if !strings.Contains(contentStr, "<h1>Heading with <em>emphasis</em>") {
t.Error("Should preserve HTML in headings")
}
if !strings.Contains(contentStr, "<strong>formatting</strong>") {
t.Error("Should preserve HTML in paragraphs")
}
}
// createTestCourseForHTML creates a test course for HTML export testing.
func createTestCourseForHTML() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "<p>Test course description with <strong>formatting</strong>.</p>",
NavigationMode: "free",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Description: "<p>Test lesson description</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Test Heading</h2>",
Paragraph: "<p>Test paragraph content.</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First list item</p>"},
{Paragraph: "<p>Second list item</p>"},
},
},
},
},
},
},
}
}
// BenchmarkHTMLExporter_Export benchmarks the Export method.
func BenchmarkHTMLExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
course := createTestCourseForHTML()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.html")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkHTMLExporter_ProcessTextItem benchmarks text item processing.
func BenchmarkHTMLExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &HTMLExporter{htmlCleaner: htmlCleaner}
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Benchmark Heading</h1>",
Paragraph: "<p>Benchmark paragraph with <strong>formatting</strong>.</p>",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
exporter.processTextItem(&buf, item)
}
}
// BenchmarkHTMLExporter_ComplexCourse benchmarks export of a complex course.
func BenchmarkHTMLExporter_ComplexCourse(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewHTMLExporter(htmlCleaner)
// Create complex course for benchmarking
course := &models.Course{
ShareID: "benchmark-id",
Course: models.CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Description: "<p>Complex course for performance testing</p>",
Lessons: make([]models.Lesson, 10), // 10 lessons
},
}
// Fill with test data
for i := 0; i < 10; i++ {
lesson := models.Lesson{
ID: "lesson-" + string(rune(i)),
Title: "Lesson " + string(rune(i)),
Type: "lesson",
Items: make([]models.Item, 5), // 5 items per lesson
}
for j := 0; j < 5; j++ {
item := models.Item{
Type: "text",
Items: make([]models.SubItem, 3), // 3 sub-items per item
}
for k := 0; k < 3; k++ {
item.Items[k] = models.SubItem{
Heading: "<h3>Heading " + string(rune(k)) + "</h3>",
Paragraph: "<p>Paragraph content with <strong>formatting</strong> for performance testing.</p>",
}
}
lesson.Items[j] = item
}
course.Course.Lessons[i] = lesson
}
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-complex.html")
_ = exporter.Export(course, outputPath)
os.Remove(outputPath)
}
}

View File

@ -65,13 +65,15 @@ func (e *MarkdownExporter) Export(course *models.Course, outputPath string) erro
buf.WriteString("\n---\n\n")
// Process lessons
for i, lesson := range course.Course.Lessons {
lessonCounter := 0
for _, lesson := range course.Course.Lessons {
if lesson.Type == "section" {
buf.WriteString(fmt.Sprintf("# %s\n\n", lesson.Title))
continue
}
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", i+1, lesson.Title))
lessonCounter++
buf.WriteString(fmt.Sprintf("## Lesson %d: %s\n\n", lessonCounter, lesson.Title))
if lesson.Description != "" {
buf.WriteString(fmt.Sprintf("%s\n\n", e.htmlCleaner.CleanHTML(lesson.Description)))
@ -110,6 +112,26 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
switch item.Type {
case "text":
e.processTextItem(buf, item, headingPrefix)
case "list":
e.processListItem(buf, item)
case "multimedia":
e.processMultimediaItem(buf, item, headingPrefix)
case "image":
e.processImageItem(buf, item, headingPrefix)
case "knowledgeCheck":
e.processKnowledgeCheckItem(buf, item, headingPrefix)
case "interactive":
e.processInteractiveItem(buf, item, headingPrefix)
case "divider":
e.processDividerItem(buf)
default:
e.processUnknownItem(buf, item, headingPrefix)
}
}
// processTextItem handles text content with headings and paragraphs
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)
@ -124,8 +146,10 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
}
}
}
}
case "list":
// processListItem handles list items with bullet points
func (e *MarkdownExporter) processListItem(buf *bytes.Buffer, item models.Item) {
for _, subItem := range item.Items {
if subItem.Paragraph != "" {
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
@ -135,29 +159,48 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
}
}
buf.WriteString("\n")
}
case "multimedia":
// processMultimediaItem handles multimedia content including videos and images
func (e *MarkdownExporter) processMultimediaItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Media Content\n\n", headingPrefix))
for _, subItem := range item.Items {
e.processMediaSubItem(buf, subItem)
}
buf.WriteString("\n")
}
// processMediaSubItem processes individual media items (video/image)
func (e *MarkdownExporter) processMediaSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Media != nil {
if subItem.Media.Video != nil {
buf.WriteString(fmt.Sprintf("**Video**: %s\n", subItem.Media.Video.OriginalUrl))
if subItem.Media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf("**Duration**: %d seconds\n", subItem.Media.Video.Duration))
}
}
if subItem.Media.Image != nil {
buf.WriteString(fmt.Sprintf("**Image**: %s\n", subItem.Media.Image.OriginalUrl))
}
e.processVideoMedia(buf, subItem.Media)
e.processImageMedia(buf, subItem.Media)
}
if subItem.Caption != "" {
caption := e.htmlCleaner.CleanHTML(subItem.Caption)
buf.WriteString(fmt.Sprintf("*%s*\n", caption))
}
}
buf.WriteString("\n")
}
case "image":
// processVideoMedia processes video media content
func (e *MarkdownExporter) processVideoMedia(buf *bytes.Buffer, media *models.Media) {
if media.Video != nil {
buf.WriteString(fmt.Sprintf("**Video**: %s\n", media.Video.OriginalUrl))
if media.Video.Duration > 0 {
buf.WriteString(fmt.Sprintf("**Duration**: %d seconds\n", media.Video.Duration))
}
}
}
// processImageMedia processes image media content
func (e *MarkdownExporter) processImageMedia(buf *bytes.Buffer, media *models.Media) {
if media.Image != nil {
buf.WriteString(fmt.Sprintf("**Image**: %s\n", media.Image.OriginalUrl))
}
}
// processImageItem handles standalone image items
func (e *MarkdownExporter) processImageItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Image\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Media != nil && subItem.Media.Image != nil {
@ -169,32 +212,46 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
}
}
buf.WriteString("\n")
}
case "knowledgeCheck":
// processKnowledgeCheckItem handles quiz questions and knowledge checks
func (e *MarkdownExporter) processKnowledgeCheckItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Knowledge Check\n\n", headingPrefix))
for _, subItem := range item.Items {
e.processQuestionSubItem(buf, subItem)
}
buf.WriteString("\n")
}
// processQuestionSubItem processes individual question items
func (e *MarkdownExporter) processQuestionSubItem(buf *bytes.Buffer, subItem models.SubItem) {
if subItem.Title != "" {
title := e.htmlCleaner.CleanHTML(subItem.Title)
buf.WriteString(fmt.Sprintf("**Question**: %s\n\n", title))
}
e.processAnswers(buf, subItem.Answers)
if subItem.Feedback != "" {
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
buf.WriteString(fmt.Sprintf("\n**Feedback**: %s\n", feedback))
}
}
// processAnswers processes answer choices for quiz questions
func (e *MarkdownExporter) processAnswers(buf *bytes.Buffer, answers []models.Answer) {
buf.WriteString("**Answers**:\n")
for i, answer := range subItem.Answers {
for i, answer := range answers {
correctMark := ""
if answer.Correct {
correctMark = " ✓"
}
buf.WriteString(fmt.Sprintf("%d. %s%s\n", i+1, answer.Title, correctMark))
}
}
if subItem.Feedback != "" {
feedback := e.htmlCleaner.CleanHTML(subItem.Feedback)
buf.WriteString(fmt.Sprintf("\n**Feedback**: %s\n", feedback))
}
}
buf.WriteString("\n")
case "interactive":
// processInteractiveItem handles interactive content
func (e *MarkdownExporter) processInteractiveItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
buf.WriteString(fmt.Sprintf("%s Interactive Content\n\n", headingPrefix))
for _, subItem := range item.Items {
if subItem.Title != "" {
@ -202,15 +259,25 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
}
}
}
case "divider":
// processDividerItem handles divider elements
func (e *MarkdownExporter) processDividerItem(buf *bytes.Buffer) {
buf.WriteString("---\n\n")
}
default:
// Handle unknown types
// processUnknownItem handles unknown or unsupported item types
func (e *MarkdownExporter) processUnknownItem(buf *bytes.Buffer, item models.Item, headingPrefix string) {
if len(item.Items) > 0 {
buf.WriteString(fmt.Sprintf("%s %s Content\n\n", headingPrefix, strings.Title(item.Type)))
for _, subItem := range item.Items {
e.processGenericSubItem(buf, subItem)
}
}
}
// 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)
buf.WriteString(fmt.Sprintf("**%s**\n\n", title))
@ -219,7 +286,4 @@ func (e *MarkdownExporter) processItemToMarkdown(buf *bytes.Buffer, item models.
paragraph := e.htmlCleaner.CleanHTML(subItem.Paragraph)
buf.WriteString(fmt.Sprintf("%s\n\n", paragraph))
}
}
}
}
}

View File

@ -0,0 +1,693 @@
// Package exporters_test provides tests for the markdown exporter.
package exporters
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/kjanat/articulate-parser/internal/models"
"github.com/kjanat/articulate-parser/internal/services"
)
// TestNewMarkdownExporter tests the NewMarkdownExporter constructor.
func TestNewMarkdownExporter(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
if exporter == nil {
t.Fatal("NewMarkdownExporter() returned nil")
}
// Type assertion to check internal structure
markdownExporter, ok := exporter.(*MarkdownExporter)
if !ok {
t.Fatal("NewMarkdownExporter() returned wrong type")
}
if markdownExporter.htmlCleaner == nil {
t.Error("htmlCleaner should not be nil")
}
}
// TestMarkdownExporter_GetSupportedFormat tests the GetSupportedFormat method.
func TestMarkdownExporter_GetSupportedFormat(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
expected := "markdown"
result := exporter.GetSupportedFormat()
if result != expected {
t.Errorf("Expected format '%s', got '%s'", expected, result)
}
}
// TestMarkdownExporter_Export tests the Export method.
func TestMarkdownExporter_Export(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
// Create test course
testCourse := createTestCourseForMarkdown()
// Create temporary directory and file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test-course.md")
// Test successful export
err := exporter.Export(testCourse, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Check that file was created
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify main course title
if !strings.Contains(contentStr, "# Test Course") {
t.Error("Output should contain course title as main heading")
}
// Verify course information section
if !strings.Contains(contentStr, "## Course Information") {
t.Error("Output should contain course information section")
}
// Verify course metadata
if !strings.Contains(contentStr, "- **Course ID**: test-course-id") {
t.Error("Output should contain course ID")
}
if !strings.Contains(contentStr, "- **Share ID**: test-share-id") {
t.Error("Output should contain share ID")
}
// Verify lesson content
if !strings.Contains(contentStr, "## Lesson 1: Test Lesson") {
t.Error("Output should contain lesson heading")
}
// Verify section handling
if !strings.Contains(contentStr, "# Test Section") {
t.Error("Output should contain section as main heading")
}
}
// TestMarkdownExporter_Export_InvalidPath tests export with invalid output path.
func TestMarkdownExporter_Export_InvalidPath(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
testCourse := createTestCourseForMarkdown()
// Try to write to invalid path
invalidPath := "/invalid/path/that/does/not/exist/file.md"
err := exporter.Export(testCourse, invalidPath)
if err == nil {
t.Fatal("Expected error for invalid path, got nil")
}
}
// TestMarkdownExporter_ProcessTextItem tests the processTextItem method.
func TestMarkdownExporter_ProcessTextItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Test Heading</h1>",
Paragraph: "<p>Test paragraph with <strong>bold</strong> text.</p>",
},
{
Paragraph: "<p>Another paragraph.</p>",
},
},
}
exporter.processTextItem(&buf, item, "###")
result := buf.String()
expected := "### Test Heading\n\nTest paragraph with bold text.\n\nAnother paragraph.\n\n"
if result != expected {
t.Errorf("Expected:\n%q\nGot:\n%q", expected, result)
}
}
// TestMarkdownExporter_ProcessListItem tests the processListItem method.
func TestMarkdownExporter_ProcessListItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First item</p>"},
{Paragraph: "<p>Second item with <em>emphasis</em></p>"},
{Paragraph: "<p>Third item</p>"},
},
}
exporter.processListItem(&buf, item)
result := buf.String()
expected := "- First item\n- Second item with emphasis\n- Third item\n\n"
if result != expected {
t.Errorf("Expected:\n%q\nGot:\n%q", expected, result)
}
}
// TestMarkdownExporter_ProcessMultimediaItem tests the processMultimediaItem method.
func TestMarkdownExporter_ProcessMultimediaItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "multimedia",
Items: []models.SubItem{
{
Media: &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 120,
},
},
Caption: "<p>Video caption</p>",
},
},
}
exporter.processMultimediaItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Media Content") {
t.Error("Should contain media content heading")
}
if !strings.Contains(result, "**Video**: https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "**Duration**: 120 seconds") {
t.Error("Should contain video duration")
}
if !strings.Contains(result, "*Video caption*") {
t.Error("Should contain video caption")
}
}
// TestMarkdownExporter_ProcessImageItem tests the processImageItem method.
func TestMarkdownExporter_ProcessImageItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "image",
Items: []models.SubItem{
{
Media: &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.jpg",
},
},
Caption: "<p>Image caption</p>",
},
},
}
exporter.processImageItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Image") {
t.Error("Should contain image heading")
}
if !strings.Contains(result, "**Image**: https://example.com/image.jpg") {
t.Error("Should contain image URL")
}
if !strings.Contains(result, "*Image caption*") {
t.Error("Should contain image caption")
}
}
// TestMarkdownExporter_ProcessKnowledgeCheckItem tests the processKnowledgeCheckItem method.
func TestMarkdownExporter_ProcessKnowledgeCheckItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What is the capital of France?</p>",
Answers: []models.Answer{
{Title: "London", Correct: false},
{Title: "Paris", Correct: true},
{Title: "Berlin", Correct: false},
},
Feedback: "<p>Paris is the capital of France.</p>",
},
},
}
exporter.processKnowledgeCheckItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Knowledge Check") {
t.Error("Should contain knowledge check heading")
}
if !strings.Contains(result, "**Question**: What is the capital of France?") {
t.Error("Should contain question")
}
if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "2. Paris ✓") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "**Feedback**: Paris is the capital of France.") {
t.Error("Should contain feedback")
}
}
// TestMarkdownExporter_ProcessInteractiveItem tests the processInteractiveItem method.
func TestMarkdownExporter_ProcessInteractiveItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "interactive",
Items: []models.SubItem{
{Title: "<p>Interactive element title</p>"},
},
}
exporter.processInteractiveItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Interactive Content") {
t.Error("Should contain interactive content heading")
}
if !strings.Contains(result, "**Interactive element title**") {
t.Error("Should contain interactive element title")
}
}
// TestMarkdownExporter_ProcessDividerItem tests the processDividerItem method.
func TestMarkdownExporter_ProcessDividerItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
exporter.processDividerItem(&buf)
result := buf.String()
expected := "---\n\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestMarkdownExporter_ProcessUnknownItem tests the processUnknownItem method.
func TestMarkdownExporter_ProcessUnknownItem(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
item := models.Item{
Type: "unknown",
Items: []models.SubItem{
{
Title: "<p>Unknown item title</p>",
Paragraph: "<p>Unknown item content</p>",
},
},
}
exporter.processUnknownItem(&buf, item, "###")
result := buf.String()
if !strings.Contains(result, "### Unknown Content") {
t.Error("Should contain unknown content heading")
}
if !strings.Contains(result, "**Unknown item title**") {
t.Error("Should contain unknown item title")
}
if !strings.Contains(result, "Unknown item content") {
t.Error("Should contain unknown item content")
}
}
// TestMarkdownExporter_ProcessVideoMedia tests the processVideoMedia method.
func TestMarkdownExporter_ProcessVideoMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
media := &models.Media{
Video: &models.VideoMedia{
OriginalUrl: "https://example.com/video.mp4",
Duration: 300,
},
}
exporter.processVideoMedia(&buf, media)
result := buf.String()
if !strings.Contains(result, "**Video**: https://example.com/video.mp4") {
t.Error("Should contain video URL")
}
if !strings.Contains(result, "**Duration**: 300 seconds") {
t.Error("Should contain video duration")
}
}
// TestMarkdownExporter_ProcessImageMedia tests the processImageMedia method.
func TestMarkdownExporter_ProcessImageMedia(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
media := &models.Media{
Image: &models.ImageMedia{
OriginalUrl: "https://example.com/image.jpg",
},
}
exporter.processImageMedia(&buf, media)
result := buf.String()
expected := "**Image**: https://example.com/image.jpg\n"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
}
// TestMarkdownExporter_ProcessAnswers tests the processAnswers method.
func TestMarkdownExporter_ProcessAnswers(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
var buf bytes.Buffer
answers := []models.Answer{
{Title: "Answer 1", Correct: false},
{Title: "Answer 2", Correct: true},
{Title: "Answer 3", Correct: false},
}
exporter.processAnswers(&buf, answers)
result := buf.String()
if !strings.Contains(result, "**Answers**:") {
t.Error("Should contain answers heading")
}
if !strings.Contains(result, "1. Answer 1") {
t.Error("Should contain first answer")
}
if !strings.Contains(result, "2. Answer 2 ✓") {
t.Error("Should mark correct answer")
}
if !strings.Contains(result, "3. Answer 3") {
t.Error("Should contain third answer")
}
}
// TestMarkdownExporter_ProcessItemToMarkdown_AllTypes tests all item types.
func TestMarkdownExporter_ProcessItemToMarkdown_AllTypes(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
tests := []struct {
name string
itemType string
expectedText string
}{
{
name: "text item",
itemType: "text",
expectedText: "", // processTextItem handles empty items
},
{
name: "list item",
itemType: "list",
expectedText: "\n", // Empty list adds newline
},
{
name: "multimedia item",
itemType: "multimedia",
expectedText: "### Media Content",
},
{
name: "image item",
itemType: "image",
expectedText: "### Image",
},
{
name: "knowledgeCheck item",
itemType: "knowledgeCheck",
expectedText: "### Knowledge Check",
},
{
name: "interactive item",
itemType: "interactive",
expectedText: "### Interactive Content",
},
{
name: "divider item",
itemType: "divider",
expectedText: "---",
},
{
name: "unknown item",
itemType: "unknown",
expectedText: "", // Empty unknown items don't add content
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
item := models.Item{Type: tt.itemType}
exporter.processItemToMarkdown(&buf, item, 3)
result := buf.String()
if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) {
t.Errorf("Expected result to contain %q, got %q", tt.expectedText, result)
}
})
}
}
// TestMarkdownExporter_ComplexCourse tests export of a complex course structure.
func TestMarkdownExporter_ComplexCourse(t *testing.T) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
// Create complex test course
course := &models.Course{
ShareID: "complex-test-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "complex-course",
Title: "Complex Test Course",
Description: "<p>This is a <strong>complex</strong> course description.</p>",
NavigationMode: "menu",
ExportSettings: &models.ExportSettings{
Format: "scorm",
},
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Course Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Introduction Lesson",
Type: "lesson",
Description: "<p>Introduction to the course</p>",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h2>Welcome</h2>",
Paragraph: "<p>Welcome to our course!</p>",
},
},
},
{
Type: "list",
Items: []models.SubItem{
{Paragraph: "<p>First objective</p>"},
{Paragraph: "<p>Second objective</p>"},
},
},
{
Type: "knowledgeCheck",
Items: []models.SubItem{
{
Title: "<p>What will you learn?</p>",
Answers: []models.Answer{
{Title: "Nothing", Correct: false},
{Title: "Everything", Correct: true},
},
Feedback: "<p>Great choice!</p>",
},
},
},
},
},
},
},
}
// Create temporary output file
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "complex-course.md")
// Export course
err := exporter.Export(course, outputPath)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Read and verify content
content, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Verify various elements are present
checks := []string{
"# Complex Test Course",
"This is a complex course description.",
"- **Export Format**: scorm",
"# Course Section",
"## Lesson 1: Introduction Lesson",
"Introduction to the course",
"### Welcome",
"Welcome to our course!",
"- First objective",
"- Second objective",
"### Knowledge Check",
"**Question**: What will you learn?",
"2. Everything ✓",
"**Feedback**: Great choice!",
}
for _, check := range checks {
if !strings.Contains(contentStr, check) {
t.Errorf("Output should contain: %q", check)
}
}
}
// createTestCourseForMarkdown creates a test course for markdown export testing.
func createTestCourseForMarkdown() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "Test course description",
NavigationMode: "menu",
Lessons: []models.Lesson{
{
ID: "section-1",
Title: "Test Section",
Type: "section",
},
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Items: []models.Item{
{
Type: "text",
Items: []models.SubItem{
{
Heading: "Test Heading",
Paragraph: "Test paragraph content",
},
},
},
},
},
},
},
}
}
// BenchmarkMarkdownExporter_Export benchmarks the Export method.
func BenchmarkMarkdownExporter_Export(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := NewMarkdownExporter(htmlCleaner)
course := createTestCourseForMarkdown()
// Create temporary directory
tempDir := b.TempDir()
b.ResetTimer()
for i := 0; i < b.N; i++ {
outputPath := filepath.Join(tempDir, "benchmark-course.md")
_ = exporter.Export(course, outputPath)
// Clean up for next iteration
os.Remove(outputPath)
}
}
// BenchmarkMarkdownExporter_ProcessTextItem benchmarks the processTextItem method.
func BenchmarkMarkdownExporter_ProcessTextItem(b *testing.B) {
htmlCleaner := services.NewHTMLCleaner()
exporter := &MarkdownExporter{htmlCleaner: htmlCleaner}
item := models.Item{
Type: "text",
Items: []models.SubItem{
{
Heading: "<h1>Benchmark Heading</h1>",
Paragraph: "<p>Benchmark paragraph with <strong>bold</strong> text.</p>",
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
exporter.processTextItem(&buf, item, "###")
}
}

View File

@ -0,0 +1,790 @@
// Package models_test provides tests for the data models.
package models
import (
"encoding/json"
"reflect"
"testing"
)
// TestCourse_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Course.
func TestCourse_JSONMarshalUnmarshal(t *testing.T) {
original := Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: CourseInfo{
ID: "course-123",
Title: "Test Course",
Description: "A test course description",
Color: "#FF5733",
NavigationMode: "menu",
Lessons: []Lesson{
{
ID: "lesson-1",
Title: "First Lesson",
Description: "Lesson description",
Type: "lesson",
Icon: "icon-1",
Ready: true,
CreatedAt: "2023-01-01T00:00:00Z",
UpdatedAt: "2023-01-02T00:00:00Z",
},
},
ExportSettings: &ExportSettings{
Title: "Export Title",
Format: "scorm",
},
},
LabelSet: LabelSet{
ID: "labelset-1",
Name: "Test Labels",
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Course to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Course
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Course from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Course structs do not match")
t.Logf("Original: %+v", original)
t.Logf("Unmarshaled: %+v", unmarshaled)
}
}
// TestCourseInfo_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of CourseInfo.
func TestCourseInfo_JSONMarshalUnmarshal(t *testing.T) {
original := CourseInfo{
ID: "course-456",
Title: "Another Test Course",
Description: "Another test description",
Color: "#33FF57",
NavigationMode: "linear",
Lessons: []Lesson{
{
ID: "lesson-2",
Title: "Second Lesson",
Type: "section",
Items: []Item{
{
ID: "item-1",
Type: "text",
Family: "text",
Variant: "paragraph",
Items: []SubItem{
{
Title: "Sub Item Title",
Heading: "Sub Item Heading",
Paragraph: "Sub item paragraph content",
},
},
},
},
},
},
CoverImage: &Media{
Image: &ImageMedia{
Key: "img-123",
Type: "jpg",
Width: 800,
Height: 600,
OriginalUrl: "https://example.com/image.jpg",
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal CourseInfo to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled CourseInfo
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal CourseInfo from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled CourseInfo structs do not match")
}
}
// TestLesson_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Lesson.
func TestLesson_JSONMarshalUnmarshal(t *testing.T) {
original := Lesson{
ID: "lesson-test",
Title: "Test Lesson",
Description: "Test lesson description",
Type: "lesson",
Icon: "lesson-icon",
Ready: true,
CreatedAt: "2023-06-01T12:00:00Z",
UpdatedAt: "2023-06-01T13:00:00Z",
Position: map[string]interface{}{"x": 1, "y": 2},
Items: []Item{
{
ID: "item-test",
Type: "multimedia",
Family: "media",
Variant: "video",
Items: []SubItem{
{
Caption: "Video caption",
Media: &Media{
Video: &VideoMedia{
Key: "video-123",
URL: "https://example.com/video.mp4",
Type: "mp4",
Duration: 120,
OriginalUrl: "https://example.com/video.mp4",
},
},
},
},
Settings: map[string]interface{}{"autoplay": false},
Data: map[string]interface{}{"metadata": "test"},
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Lesson to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Lesson
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Lesson from JSON: %v", err)
}
// Compare structures
if !compareLessons(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Lesson structs do not match")
}
}
// TestItem_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Item.
func TestItem_JSONMarshalUnmarshal(t *testing.T) {
original := Item{
ID: "item-json-test",
Type: "knowledgeCheck",
Family: "assessment",
Variant: "multipleChoice",
Items: []SubItem{
{
Title: "What is the answer?",
Answers: []Answer{
{Title: "Option A", Correct: false},
{Title: "Option B", Correct: true},
{Title: "Option C", Correct: false},
},
Feedback: "Well done!",
},
},
Settings: map[string]interface{}{
"allowRetry": true,
"showAnswer": true,
},
Data: map[string]interface{}{
"points": 10,
"weight": 1.5,
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Item to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Item
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Item from JSON: %v", err)
}
// Compare structures
if !compareItem(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Item structs do not match")
}
}
// TestSubItem_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of SubItem.
func TestSubItem_JSONMarshalUnmarshal(t *testing.T) {
original := SubItem{
Title: "Test SubItem Title",
Heading: "Test SubItem Heading",
Paragraph: "Test paragraph with content",
Caption: "Test caption",
Feedback: "Test feedback message",
Answers: []Answer{
{Title: "First answer", Correct: true},
{Title: "Second answer", Correct: false},
},
Media: &Media{
Image: &ImageMedia{
Key: "subitem-img",
Type: "png",
Width: 400,
Height: 300,
OriginalUrl: "https://example.com/subitem.png",
CrushedKey: "crushed-123",
UseCrushedKey: true,
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal SubItem to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled SubItem
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal SubItem from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled SubItem structs do not match")
}
}
// TestAnswer_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Answer.
func TestAnswer_JSONMarshalUnmarshal(t *testing.T) {
original := Answer{
Title: "Test answer text",
Correct: true,
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal Answer to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Answer
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Answer from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled Answer structs do not match")
}
}
// TestMedia_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of Media.
func TestMedia_JSONMarshalUnmarshal(t *testing.T) {
// Test with Image
originalImage := Media{
Image: &ImageMedia{
Key: "media-img-test",
Type: "jpeg",
Width: 1200,
Height: 800,
OriginalUrl: "https://example.com/media.jpg",
CrushedKey: "crushed-media",
UseCrushedKey: false,
},
}
jsonData, err := json.Marshal(originalImage)
if err != nil {
t.Fatalf("Failed to marshal Media with Image to JSON: %v", err)
}
var unmarshaledImage Media
err = json.Unmarshal(jsonData, &unmarshaledImage)
if err != nil {
t.Fatalf("Failed to unmarshal Media with Image from JSON: %v", err)
}
if !reflect.DeepEqual(originalImage, unmarshaledImage) {
t.Errorf("Marshaled and unmarshaled Media with Image do not match")
}
// Test with Video
originalVideo := Media{
Video: &VideoMedia{
Key: "media-video-test",
URL: "https://example.com/media.mp4",
Type: "mp4",
Duration: 300,
Poster: "https://example.com/poster.jpg",
Thumbnail: "https://example.com/thumb.jpg",
InputKey: "input-123",
OriginalUrl: "https://example.com/original.mp4",
},
}
jsonData, err = json.Marshal(originalVideo)
if err != nil {
t.Fatalf("Failed to marshal Media with Video to JSON: %v", err)
}
var unmarshaledVideo Media
err = json.Unmarshal(jsonData, &unmarshaledVideo)
if err != nil {
t.Fatalf("Failed to unmarshal Media with Video from JSON: %v", err)
}
if !reflect.DeepEqual(originalVideo, unmarshaledVideo) {
t.Errorf("Marshaled and unmarshaled Media with Video do not match")
}
}
// TestImageMedia_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of ImageMedia.
func TestImageMedia_JSONMarshalUnmarshal(t *testing.T) {
original := ImageMedia{
Key: "image-media-test",
Type: "gif",
Width: 640,
Height: 480,
OriginalUrl: "https://example.com/image.gif",
CrushedKey: "crushed-gif",
UseCrushedKey: true,
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal ImageMedia to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled ImageMedia
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal ImageMedia from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled ImageMedia structs do not match")
}
}
// TestVideoMedia_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of VideoMedia.
func TestVideoMedia_JSONMarshalUnmarshal(t *testing.T) {
original := VideoMedia{
Key: "video-media-test",
URL: "https://example.com/video.webm",
Type: "webm",
Duration: 450,
Poster: "https://example.com/poster.jpg",
Thumbnail: "https://example.com/thumbnail.jpg",
InputKey: "upload-456",
OriginalUrl: "https://example.com/original.webm",
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal VideoMedia to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled VideoMedia
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal VideoMedia from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled VideoMedia structs do not match")
}
}
// TestExportSettings_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of ExportSettings.
func TestExportSettings_JSONMarshalUnmarshal(t *testing.T) {
original := ExportSettings{
Title: "Custom Export Title",
Format: "xAPI",
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal ExportSettings to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled ExportSettings
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal ExportSettings from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled ExportSettings structs do not match")
}
}
// TestLabelSet_JSONMarshalUnmarshal tests JSON marshaling and unmarshaling of LabelSet.
func TestLabelSet_JSONMarshalUnmarshal(t *testing.T) {
original := LabelSet{
ID: "labelset-test",
Name: "Test Label Set",
}
// Marshal to JSON
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal LabelSet to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled LabelSet
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal LabelSet from JSON: %v", err)
}
// Compare structures
if !reflect.DeepEqual(original, unmarshaled) {
t.Errorf("Marshaled and unmarshaled LabelSet structs do not match")
}
}
// TestEmptyStructures tests marshaling and unmarshaling of empty structures.
func TestEmptyStructures(t *testing.T) {
testCases := []struct {
name string
data interface{}
}{
{"Empty Course", Course{}},
{"Empty CourseInfo", CourseInfo{}},
{"Empty Lesson", Lesson{}},
{"Empty Item", Item{}},
{"Empty SubItem", SubItem{}},
{"Empty Answer", Answer{}},
{"Empty Media", Media{}},
{"Empty ImageMedia", ImageMedia{}},
{"Empty VideoMedia", VideoMedia{}},
{"Empty ExportSettings", ExportSettings{}},
{"Empty LabelSet", LabelSet{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Marshal to JSON
jsonData, err := json.Marshal(tc.data)
if err != nil {
t.Fatalf("Failed to marshal %s to JSON: %v", tc.name, err)
}
// Unmarshal from JSON
result := reflect.New(reflect.TypeOf(tc.data)).Interface()
err = json.Unmarshal(jsonData, result)
if err != nil {
t.Fatalf("Failed to unmarshal %s from JSON: %v", tc.name, err)
}
// Basic validation that no errors occurred
if len(jsonData) == 0 {
t.Errorf("%s should produce some JSON output", tc.name)
}
})
}
}
// TestNilPointerSafety tests that nil pointers in optional fields are handled correctly.
func TestNilPointerSafety(t *testing.T) {
course := Course{
ShareID: "nil-test",
Course: CourseInfo{
ID: "nil-course",
Title: "Nil Pointer Test",
CoverImage: nil, // Test nil pointer
ExportSettings: nil, // Test nil pointer
Lessons: []Lesson{
{
ID: "lesson-nil",
Title: "Lesson with nil media",
Items: []Item{
{
ID: "item-nil",
Type: "text",
Items: []SubItem{
{
Title: "SubItem with nil media",
Media: nil, // Test nil pointer
},
},
Media: nil, // Test nil pointer
},
},
},
},
},
}
// Marshal to JSON
jsonData, err := json.Marshal(course)
if err != nil {
t.Fatalf("Failed to marshal Course with nil pointers to JSON: %v", err)
}
// Unmarshal from JSON
var unmarshaled Course
err = json.Unmarshal(jsonData, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal Course with nil pointers from JSON: %v", err)
}
// Basic validation
if unmarshaled.ShareID != "nil-test" {
t.Error("ShareID should be preserved")
}
if unmarshaled.Course.Title != "Nil Pointer Test" {
t.Error("Course title should be preserved")
}
}
// TestJSONTagsPresence tests that JSON tags are properly defined.
func TestJSONTagsPresence(t *testing.T) {
// Test that important fields have JSON tags
courseType := reflect.TypeOf(Course{})
if courseType.Kind() == reflect.Struct {
field, found := courseType.FieldByName("ShareID")
if !found {
t.Error("ShareID field not found")
} else {
tag := field.Tag.Get("json")
if tag == "" {
t.Error("ShareID should have json tag")
}
if tag != "shareId" {
t.Errorf("ShareID json tag should be 'shareId', got '%s'", tag)
}
}
}
// Test CourseInfo
courseInfoType := reflect.TypeOf(CourseInfo{})
if courseInfoType.Kind() == reflect.Struct {
field, found := courseInfoType.FieldByName("NavigationMode")
if !found {
t.Error("NavigationMode field not found")
} else {
tag := field.Tag.Get("json")
if tag == "" {
t.Error("NavigationMode should have json tag")
}
}
}
}
// BenchmarkCourse_JSONMarshal benchmarks JSON marshaling of Course.
func BenchmarkCourse_JSONMarshal(b *testing.B) {
course := Course{
ShareID: "benchmark-id",
Author: "Benchmark Author",
Course: CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Lessons: []Lesson{
{
ID: "lesson-1",
Title: "Lesson 1",
Items: []Item{
{
ID: "item-1",
Type: "text",
Items: []SubItem{
{Title: "SubItem 1"},
},
},
},
},
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(course)
}
}
// BenchmarkCourse_JSONUnmarshal benchmarks JSON unmarshaling of Course.
func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
course := Course{
ShareID: "benchmark-id",
Author: "Benchmark Author",
Course: CourseInfo{
ID: "benchmark-course",
Title: "Benchmark Course",
Lessons: []Lesson{
{
ID: "lesson-1",
Title: "Lesson 1",
Items: []Item{
{
ID: "item-1",
Type: "text",
Items: []SubItem{
{Title: "SubItem 1"},
},
},
},
},
},
},
}
jsonData, _ := json.Marshal(course)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result Course
_ = json.Unmarshal(jsonData, &result)
}
}
// compareMaps compares two interface{} values that should be maps
func compareMaps(original, unmarshaled interface{}) bool {
origMap, origOk := original.(map[string]interface{})
unMap, unOk := unmarshaled.(map[string]interface{})
if !origOk || !unOk {
// If not maps, use deep equal
return reflect.DeepEqual(original, unmarshaled)
}
if len(origMap) != len(unMap) {
return false
}
for key, origVal := range origMap {
unVal, exists := unMap[key]
if !exists {
return false
}
// Handle numeric type conversion from JSON
switch origVal := origVal.(type) {
case int:
if unFloat, ok := unVal.(float64); ok {
if float64(origVal) != unFloat {
return false
}
} else {
return false
}
case float64:
if unFloat, ok := unVal.(float64); ok {
if origVal != unFloat {
return false
}
} else {
return false
}
default:
if !reflect.DeepEqual(origVal, unVal) {
return false
}
}
}
return true
}
// compareLessons compares two Lesson structs accounting for JSON type conversion
func compareLessons(original, unmarshaled Lesson) bool {
// Compare all fields except Position and Items
if original.ID != unmarshaled.ID ||
original.Title != unmarshaled.Title ||
original.Description != unmarshaled.Description ||
original.Type != unmarshaled.Type ||
original.Icon != unmarshaled.Icon ||
original.Ready != unmarshaled.Ready ||
original.CreatedAt != unmarshaled.CreatedAt ||
original.UpdatedAt != unmarshaled.UpdatedAt {
return false
}
// Compare Position
if !compareMaps(original.Position, unmarshaled.Position) {
return false
}
// Compare Items
return compareItems(original.Items, unmarshaled.Items)
}
// compareItems compares two Item slices accounting for JSON type conversion
func compareItems(original, unmarshaled []Item) bool {
if len(original) != len(unmarshaled) {
return false
}
for i := range original {
if !compareItem(original[i], unmarshaled[i]) {
return false
}
}
return true
}
// compareItem compares two Item structs accounting for JSON type conversion
func compareItem(original, unmarshaled Item) bool {
// Compare basic fields
if original.ID != unmarshaled.ID ||
original.Type != unmarshaled.Type ||
original.Family != unmarshaled.Family ||
original.Variant != unmarshaled.Variant {
return false
}
// Compare Settings and Data
if !compareMaps(original.Settings, unmarshaled.Settings) {
return false
}
if !compareMaps(original.Data, unmarshaled.Data) {
return false
}
// Compare Items (SubItems)
if len(original.Items) != len(unmarshaled.Items) {
return false
}
for i := range original.Items {
if !reflect.DeepEqual(original.Items[i], unmarshaled.Items[i]) {
return false
}
}
// Compare Media
if !reflect.DeepEqual(original.Media, unmarshaled.Media) {
return false
}
return true
}

View File

@ -0,0 +1,353 @@
// Package services_test provides tests for the services package.
package services
import (
"errors"
"testing"
"github.com/kjanat/articulate-parser/internal/interfaces"
"github.com/kjanat/articulate-parser/internal/models"
)
// MockCourseParser is a mock implementation of interfaces.CourseParser for testing.
type MockCourseParser struct {
mockFetchCourse func(uri string) (*models.Course, error)
mockLoadCourseFromFile func(filePath string) (*models.Course, error)
}
func (m *MockCourseParser) FetchCourse(uri string) (*models.Course, error) {
if m.mockFetchCourse != nil {
return m.mockFetchCourse(uri)
}
return nil, errors.New("not implemented")
}
func (m *MockCourseParser) LoadCourseFromFile(filePath string) (*models.Course, error) {
if m.mockLoadCourseFromFile != nil {
return m.mockLoadCourseFromFile(filePath)
}
return nil, errors.New("not implemented")
}
// MockExporter is a mock implementation of interfaces.Exporter for testing.
type MockExporter struct {
mockExport func(course *models.Course, outputPath string) error
mockGetSupportedFormat func() string
}
func (m *MockExporter) Export(course *models.Course, outputPath string) error {
if m.mockExport != nil {
return m.mockExport(course, outputPath)
}
return nil
}
func (m *MockExporter) GetSupportedFormat() string {
if m.mockGetSupportedFormat != nil {
return m.mockGetSupportedFormat()
}
return "mock"
}
// MockExporterFactory is a mock implementation of interfaces.ExporterFactory for testing.
type MockExporterFactory struct {
mockCreateExporter func(format string) (*MockExporter, error)
mockGetSupportedFormats func() []string
}
func (m *MockExporterFactory) CreateExporter(format string) (interfaces.Exporter, error) {
if m.mockCreateExporter != nil {
exporter, err := m.mockCreateExporter(format)
return exporter, err
}
return &MockExporter{}, nil
}
func (m *MockExporterFactory) GetSupportedFormats() []string {
if m.mockGetSupportedFormats != nil {
return m.mockGetSupportedFormats()
}
return []string{"mock"}
}
// createTestCourse creates a sample course for testing purposes.
func createTestCourse() *models.Course {
return &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "This is a test course",
Lessons: []models.Lesson{
{
ID: "lesson-1",
Title: "Test Lesson",
Type: "lesson",
Items: []models.Item{
{
ID: "item-1",
Type: "text",
Items: []models.SubItem{
{
ID: "subitem-1",
Title: "Test Title",
Paragraph: "Test paragraph content",
},
},
},
},
},
},
},
}
}
// TestNewApp tests the NewApp constructor.
func TestNewApp(t *testing.T) {
parser := &MockCourseParser{}
factory := &MockExporterFactory{}
app := NewApp(parser, factory)
if app == nil {
t.Fatal("NewApp() returned nil")
}
if app.parser != parser {
t.Error("App parser was not set correctly")
}
// Test that the factory is set (we can't directly compare interface values)
formats := app.GetSupportedFormats()
if len(formats) == 0 {
t.Error("App exporterFactory was not set correctly - no supported formats")
}
}
// TestApp_ProcessCourseFromFile tests the ProcessCourseFromFile method.
func TestApp_ProcessCourseFromFile(t *testing.T) {
testCourse := createTestCourse()
tests := []struct {
name string
filePath string
format string
outputPath string
setupMocks func(*MockCourseParser, *MockExporterFactory, *MockExporter)
expectedError string
}{
{
name: "successful processing",
filePath: "test.json",
format: "markdown",
outputPath: "output.md",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockLoadCourseFromFile = func(filePath string) (*models.Course, error) {
if filePath != "test.json" {
t.Errorf("Expected filePath 'test.json', got '%s'", filePath)
}
return testCourse, nil
}
factory.mockCreateExporter = func(format string) (*MockExporter, error) {
if format != "markdown" {
t.Errorf("Expected format 'markdown', got '%s'", format)
}
return exporter, nil
}
exporter.mockExport = func(course *models.Course, outputPath string) error {
if outputPath != "output.md" {
t.Errorf("Expected outputPath 'output.md', got '%s'", outputPath)
}
if course != testCourse {
t.Error("Expected course to match testCourse")
}
return nil
}
},
},
{
name: "file loading error",
filePath: "nonexistent.json",
format: "markdown",
outputPath: "output.md",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockLoadCourseFromFile = func(filePath string) (*models.Course, error) {
return nil, errors.New("file not found")
}
},
expectedError: "failed to load course from file",
},
{
name: "exporter creation error",
filePath: "test.json",
format: "unsupported",
outputPath: "output.txt",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockLoadCourseFromFile = func(filePath string) (*models.Course, error) {
return testCourse, nil
}
factory.mockCreateExporter = func(format string) (*MockExporter, error) {
return nil, errors.New("unsupported format")
}
},
expectedError: "failed to create exporter",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MockCourseParser{}
exporter := &MockExporter{}
factory := &MockExporterFactory{}
tt.setupMocks(parser, factory, exporter)
app := NewApp(parser, factory)
err := app.ProcessCourseFromFile(tt.filePath, tt.format, tt.outputPath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
})
}
}
// TestApp_ProcessCourseFromURI tests the ProcessCourseFromURI method.
func TestApp_ProcessCourseFromURI(t *testing.T) {
testCourse := createTestCourse()
tests := []struct {
name string
uri string
format string
outputPath string
setupMocks func(*MockCourseParser, *MockExporterFactory, *MockExporter)
expectedError string
}{
{
name: "successful processing",
uri: "https://rise.articulate.com/share/test123",
format: "docx",
outputPath: "output.docx",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockFetchCourse = func(uri string) (*models.Course, error) {
if uri != "https://rise.articulate.com/share/test123" {
t.Errorf("Expected uri 'https://rise.articulate.com/share/test123', got '%s'", uri)
}
return testCourse, nil
}
factory.mockCreateExporter = func(format string) (*MockExporter, error) {
if format != "docx" {
t.Errorf("Expected format 'docx', got '%s'", format)
}
return exporter, nil
}
exporter.mockExport = func(course *models.Course, outputPath string) error {
if outputPath != "output.docx" {
t.Errorf("Expected outputPath 'output.docx', got '%s'", outputPath)
}
return nil
}
},
},
{
name: "fetch error",
uri: "invalid-uri",
format: "docx",
outputPath: "output.docx",
setupMocks: func(parser *MockCourseParser, factory *MockExporterFactory, exporter *MockExporter) {
parser.mockFetchCourse = func(uri string) (*models.Course, error) {
return nil, errors.New("network error")
}
},
expectedError: "failed to fetch course",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := &MockCourseParser{}
exporter := &MockExporter{}
factory := &MockExporterFactory{}
tt.setupMocks(parser, factory, exporter)
app := NewApp(parser, factory)
err := app.ProcessCourseFromURI(tt.uri, tt.format, tt.outputPath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
})
}
}
// TestApp_GetSupportedFormats tests the GetSupportedFormats method.
func TestApp_GetSupportedFormats(t *testing.T) {
expectedFormats := []string{"markdown", "docx", "pdf"}
parser := &MockCourseParser{}
factory := &MockExporterFactory{
mockGetSupportedFormats: func() []string {
return expectedFormats
},
}
app := NewApp(parser, factory)
formats := app.GetSupportedFormats()
if len(formats) != len(expectedFormats) {
t.Errorf("Expected %d formats, got %d", len(expectedFormats), len(formats))
}
for i, format := range formats {
if format != expectedFormats[i] {
t.Errorf("Expected format '%s' at index %d, got '%s'", expectedFormats[i], i, format)
}
}
}
// contains checks if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(len(substr) == 0 ||
s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsSubstring(s, substr))))
}
// containsSubstring checks if s contains substr as a substring.
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@ -0,0 +1,325 @@
// Package services_test provides tests for the HTML cleaner service.
package services
import (
"strings"
"testing"
)
// TestNewHTMLCleaner tests the NewHTMLCleaner constructor.
func TestNewHTMLCleaner(t *testing.T) {
cleaner := NewHTMLCleaner()
if cleaner == nil {
t.Fatal("NewHTMLCleaner() returned nil")
}
}
// TestHTMLCleaner_CleanHTML tests the CleanHTML method with various HTML inputs.
func TestHTMLCleaner_CleanHTML(t *testing.T) {
cleaner := NewHTMLCleaner()
tests := []struct {
name string
input string
expected string
}{
{
name: "plain text (no HTML)",
input: "This is plain text",
expected: "This is plain text",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "simple HTML tag",
input: "<p>Hello world</p>",
expected: "Hello world",
},
{
name: "multiple HTML tags",
input: "<h1>Title</h1><p>Paragraph text</p>",
expected: "TitleParagraph text",
},
{
name: "nested HTML tags",
input: "<div><h1>Title</h1><p>Paragraph with <strong>bold</strong> text</p></div>",
expected: "TitleParagraph with bold text",
},
{
name: "HTML with attributes",
input: "<p class=\"test\" id=\"para1\">Text with attributes</p>",
expected: "Text with attributes",
},
{
name: "self-closing tags",
input: "Line 1<br/>Line 2<hr/>End",
expected: "Line 1Line 2End",
},
{
name: "HTML entities - basic",
input: "AT&amp;T &lt;company&gt; &quot;quoted&quot; &nbsp; text",
expected: "AT&T <company> \"quoted\" text",
},
{
name: "HTML entities - apostrophe",
input: "It&#39;s a test",
expected: "It's a test",
},
{
name: "HTML entities - special characters",
input: "&iuml;ber &euml;lite &eacute;cart&eacute;",
expected: "ïber ëlite écarté",
},
{
name: "HTML entities - nbsp",
input: "Word1&nbsp;&nbsp;&nbsp;Word2",
expected: "Word1 Word2",
},
{
name: "mixed HTML and entities",
input: "<p>Hello &amp; welcome to <strong>our</strong> site!</p>",
expected: "Hello & welcome to our site!",
},
{
name: "multiple whitespace",
input: "Text with\t\tmultiple\n\nspaces",
expected: "Text with multiple spaces",
},
{
name: "whitespace with HTML",
input: "<p> Text with </p> <div> spaces </div> ",
expected: "Text with spaces",
},
{
name: "complex content",
input: "<div class=\"content\"><h1>Course Title</h1><p>This is a <em>great</em> course about &amp; HTML entities like &nbsp; and &quot;quotes&quot;.</p></div>",
expected: "Course TitleThis is a great course about & HTML entities like and \"quotes\".",
},
{
name: "malformed HTML",
input: "<p>Unclosed paragraph<div>Another <span>tag</p></div>",
expected: "Unclosed paragraphAnother tag",
},
{
name: "HTML comments (should be removed)",
input: "Text before<!-- This is a comment -->Text after",
expected: "Text beforeText after",
},
{
name: "script and style tags content",
input: "<script>alert('test');</script>Content<style>body{color:red;}</style>",
expected: "alert('test');Contentbody{color:red;}",
},
{
name: "line breaks and formatting",
input: "<p>Line 1</p>\n<p>Line 2</p>\n<p>Line 3</p>",
expected: "Line 1 Line 2 Line 3",
},
{
name: "only whitespace",
input: " \t\n ",
expected: "",
},
{
name: "only HTML tags",
input: "<div><p></p></div>",
expected: "",
},
{
name: "HTML with newlines",
input: "<p>\n Paragraph with\n line breaks\n</p>",
expected: "Paragraph with line breaks",
},
{
name: "complex nested structure",
input: "<article><header><h1>Title</h1></header><section><p>First paragraph with <a href=\"#\">link</a>.</p><ul><li>Item 1</li><li>Item 2</li></ul></section></article>",
expected: "TitleFirst paragraph with link.Item 1Item 2",
},
{
name: "entities in attributes (should still be processed)",
input: "<p title=\"AT&amp;T\">Content</p>",
expected: "Content",
},
{
name: "special HTML5 entities",
input: "Left arrow &larr; Right arrow &rarr;",
expected: "Left arrow &larr; Right arrow &rarr;", // These are not handled by the cleaner
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleaner.CleanHTML(tt.input)
if result != tt.expected {
t.Errorf("CleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestHTMLCleaner_CleanHTML_LargeContent tests the CleanHTML method with large content.
func TestHTMLCleaner_CleanHTML_LargeContent(t *testing.T) {
cleaner := NewHTMLCleaner()
// Create a large HTML string
var builder strings.Builder
builder.WriteString("<html><body>")
for i := 0; i < 1000; i++ {
builder.WriteString("<p>Paragraph ")
builder.WriteString(string(rune('0' + i%10)))
builder.WriteString(" with some content &amp; entities.</p>")
}
builder.WriteString("</body></html>")
input := builder.String()
result := cleaner.CleanHTML(input)
// Check that HTML tags are removed
if strings.Contains(result, "<") || strings.Contains(result, ">") {
t.Error("Result should not contain HTML tags")
}
// Check that content is preserved
if !strings.Contains(result, "Paragraph") {
t.Error("Result should contain paragraph content")
}
// Check that entities are converted
if strings.Contains(result, "&amp;") {
t.Error("Result should not contain unconverted HTML entities")
}
if !strings.Contains(result, "&") {
t.Error("Result should contain converted ampersand")
}
}
// TestHTMLCleaner_CleanHTML_EdgeCases tests edge cases for the CleanHTML method.
func TestHTMLCleaner_CleanHTML_EdgeCases(t *testing.T) {
cleaner := NewHTMLCleaner()
tests := []struct {
name string
input string
expected string
}{
{
name: "only entities",
input: "&amp;&lt;&gt;&quot;&#39;&nbsp;",
expected: "&<>\"'",
},
{
name: "repeated entities",
input: "&amp;&amp;&amp;",
expected: "&&&",
},
{
name: "entities without semicolon (should not be converted)",
input: "&amp test &lt test",
expected: "&amp test &lt test",
},
{
name: "mixed valid and invalid entities",
input: "&amp; &invalid; &lt; &fake;",
expected: "& &invalid; < &fake;",
},
{
name: "unclosed tag at end",
input: "Content <p>with unclosed",
expected: "Content with unclosed",
},
{
name: "tag with no closing bracket",
input: "Content <p class='test' with no closing bracket",
expected: "Content <p class='test' with no closing bracket",
},
{
name: "extremely nested tags",
input: "<div><div><div><div><div>Deep content</div></div></div></div></div>",
expected: "Deep content",
},
{
name: "empty tags with whitespace",
input: "<p> </p><div>\t\n</div>",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleaner.CleanHTML(tt.input)
if result != tt.expected {
t.Errorf("CleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestHTMLCleaner_CleanHTML_Unicode tests Unicode content handling.
func TestHTMLCleaner_CleanHTML_Unicode(t *testing.T) {
cleaner := NewHTMLCleaner()
tests := []struct {
name string
input string
expected string
}{
{
name: "unicode characters",
input: "<p>Hello 世界! Café naïve résumé</p>",
expected: "Hello 世界! Café naïve résumé",
},
{
name: "unicode with entities",
input: "<p>Unicode: 你好 &amp; emoji: 🌍</p>",
expected: "Unicode: 你好 & emoji: 🌍",
},
{
name: "mixed scripts",
input: "<div>English العربية русский 日本語</div>",
expected: "English العربية русский 日本語",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleaner.CleanHTML(tt.input)
if result != tt.expected {
t.Errorf("CleanHTML(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// BenchmarkHTMLCleaner_CleanHTML benchmarks the CleanHTML method.
func BenchmarkHTMLCleaner_CleanHTML(b *testing.B) {
cleaner := NewHTMLCleaner()
input := "<div class=\"content\"><h1>Course Title</h1><p>This is a <em>great</em> course about &amp; HTML entities like &nbsp; and &quot;quotes&quot;.</p><ul><li>Item 1</li><li>Item 2</li></ul></div>"
b.ResetTimer()
for i := 0; i < b.N; i++ {
cleaner.CleanHTML(input)
}
}
// BenchmarkHTMLCleaner_CleanHTML_Large benchmarks the CleanHTML method with large content.
func BenchmarkHTMLCleaner_CleanHTML_Large(b *testing.B) {
cleaner := NewHTMLCleaner()
// Create a large HTML string
var builder strings.Builder
for i := 0; i < 100; i++ {
builder.WriteString("<p>Paragraph ")
builder.WriteString(string(rune('0' + i%10)))
builder.WriteString(" with some content &amp; entities &lt;test&gt;.</p>")
}
input := builder.String()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cleaner.CleanHTML(input)
}
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"time"
@ -112,6 +113,17 @@ func (p *ArticulateParser) LoadCourseFromFile(filePath string) (*models.Course,
// - The share ID string if found
// - An error if the share ID can't be extracted from the URI
func (p *ArticulateParser) extractShareID(uri string) (string, error) {
// Parse the URL to validate the domain
parsedURL, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("invalid URI: %s", uri)
}
// Validate that it's an Articulate Rise domain
if parsedURL.Host != "rise.articulate.com" {
return "", fmt.Errorf("invalid domain for Articulate Rise URI: %s", parsedURL.Host)
}
re := regexp.MustCompile(`/share/([a-zA-Z0-9_-]+)`)
matches := re.FindStringSubmatch(uri)
if len(matches) < 2 {

View File

@ -0,0 +1,440 @@
// Package services_test provides tests for the parser service.
package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kjanat/articulate-parser/internal/models"
)
// TestNewArticulateParser tests the NewArticulateParser constructor.
func TestNewArticulateParser(t *testing.T) {
parser := NewArticulateParser()
if parser == nil {
t.Fatal("NewArticulateParser() returned nil")
}
// Type assertion to check internal structure
articulateParser, ok := parser.(*ArticulateParser)
if !ok {
t.Fatal("NewArticulateParser() returned wrong type")
}
expectedBaseURL := "https://rise.articulate.com"
if articulateParser.BaseURL != expectedBaseURL {
t.Errorf("Expected BaseURL '%s', got '%s'", expectedBaseURL, articulateParser.BaseURL)
}
if articulateParser.Client == nil {
t.Error("Client should not be nil")
}
expectedTimeout := 30 * time.Second
if articulateParser.Client.Timeout != expectedTimeout {
t.Errorf("Expected timeout %v, got %v", expectedTimeout, articulateParser.Client.Timeout)
}
}
// TestArticulateParser_FetchCourse tests the FetchCourse method.
func TestArticulateParser_FetchCourse(t *testing.T) {
// Create a test course object
testCourse := &models.Course{
ShareID: "test-share-id",
Author: "Test Author",
Course: models.CourseInfo{
ID: "test-course-id",
Title: "Test Course",
Description: "Test Description",
},
}
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request path
expectedPath := "/api/rise-runtime/boot/share/test-share-id"
if r.URL.Path != expectedPath {
t.Errorf("Expected path '%s', got '%s'", expectedPath, r.URL.Path)
}
// Check request method
if r.Method != http.MethodGet {
t.Errorf("Expected method GET, got %s", r.Method)
}
// Return mock response
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(testCourse); err != nil {
t.Fatalf("Failed to encode test course: %v", err)
}
}))
defer server.Close()
// Create parser with test server URL
parser := &ArticulateParser{
BaseURL: server.URL,
Client: &http.Client{
Timeout: 5 * time.Second,
},
}
tests := []struct {
name string
uri string
expectedError string
}{
{
name: "valid articulate rise URI",
uri: "https://rise.articulate.com/share/test-share-id#/",
},
{
name: "valid articulate rise URI without fragment",
uri: "https://rise.articulate.com/share/test-share-id",
},
{
name: "invalid URI format",
uri: "invalid-uri",
expectedError: "invalid domain for Articulate Rise URI:",
},
{
name: "empty URI",
uri: "",
expectedError: "invalid domain for Articulate Rise URI:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
course, err := parser.FetchCourse(tt.uri)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if course == nil {
t.Fatal("Expected course, got nil")
}
if course.ShareID != testCourse.ShareID {
t.Errorf("Expected ShareID '%s', got '%s'", testCourse.ShareID, course.ShareID)
}
}
})
}
}
// TestArticulateParser_FetchCourse_NetworkError tests network error handling.
func TestArticulateParser_FetchCourse_NetworkError(t *testing.T) {
// Create parser with invalid URL to simulate network error
parser := &ArticulateParser{
BaseURL: "http://localhost:99999", // Invalid port
Client: &http.Client{
Timeout: 1 * time.Millisecond, // Very short timeout
},
}
_, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id")
if err == nil {
t.Fatal("Expected network error, got nil")
}
if !strings.Contains(err.Error(), "failed to fetch course data") {
t.Errorf("Expected error to contain 'failed to fetch course data', got '%s'", err.Error())
}
}
// TestArticulateParser_FetchCourse_InvalidJSON tests invalid JSON response handling.
func TestArticulateParser_FetchCourse_InvalidJSON(t *testing.T) {
// Create test server that returns invalid JSON
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("invalid json"))
}))
defer server.Close()
parser := &ArticulateParser{
BaseURL: server.URL,
Client: &http.Client{
Timeout: 5 * time.Second,
},
}
_, err := parser.FetchCourse("https://rise.articulate.com/share/test-share-id")
if err == nil {
t.Fatal("Expected JSON parsing error, got nil")
}
if !strings.Contains(err.Error(), "failed to unmarshal JSON") {
t.Errorf("Expected error to contain 'failed to unmarshal JSON', got '%s'", err.Error())
}
}
// TestArticulateParser_LoadCourseFromFile tests the LoadCourseFromFile method.
func TestArticulateParser_LoadCourseFromFile(t *testing.T) {
// Create a temporary test file
testCourse := &models.Course{
ShareID: "file-test-share-id",
Author: "File Test Author",
Course: models.CourseInfo{
ID: "file-test-course-id",
Title: "File Test Course",
Description: "File Test Description",
},
}
// Create temporary directory and file
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "test-course.json")
// Write test data to file
data, err := json.Marshal(testCourse)
if err != nil {
t.Fatalf("Failed to marshal test course: %v", err)
}
if err := os.WriteFile(tempFile, data, 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser := NewArticulateParser()
tests := []struct {
name string
filePath string
expectedError string
}{
{
name: "valid file",
filePath: tempFile,
},
{
name: "nonexistent file",
filePath: filepath.Join(tempDir, "nonexistent.json"),
expectedError: "failed to read file",
},
{
name: "empty path",
filePath: "",
expectedError: "failed to read file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
course, err := parser.LoadCourseFromFile(tt.filePath)
if tt.expectedError != "" {
if err == nil {
t.Fatalf("Expected error containing '%s', got nil", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
}
} else {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if course == nil {
t.Fatal("Expected course, got nil")
}
if course.ShareID != testCourse.ShareID {
t.Errorf("Expected ShareID '%s', got '%s'", testCourse.ShareID, course.ShareID)
}
}
})
}
}
// TestArticulateParser_LoadCourseFromFile_InvalidJSON tests invalid JSON file handling.
func TestArticulateParser_LoadCourseFromFile_InvalidJSON(t *testing.T) {
// Create temporary file with invalid JSON
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "invalid.json")
if err := os.WriteFile(tempFile, []byte("invalid json content"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
parser := NewArticulateParser()
_, err := parser.LoadCourseFromFile(tempFile)
if err == nil {
t.Fatal("Expected JSON parsing error, got nil")
}
if !strings.Contains(err.Error(), "failed to unmarshal JSON") {
t.Errorf("Expected error to contain 'failed to unmarshal JSON', got '%s'", err.Error())
}
}
// TestExtractShareID tests the extractShareID method.
func TestExtractShareID(t *testing.T) {
parser := &ArticulateParser{}
tests := []struct {
name string
uri string
expected string
hasError bool
}{
{
name: "standard articulate rise URI with fragment",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "standard articulate rise URI without fragment",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "URI with trailing slash",
uri: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO/",
expected: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "short share ID",
uri: "https://rise.articulate.com/share/abc123",
expected: "abc123",
},
{
name: "share ID with hyphens and underscores",
uri: "https://rise.articulate.com/share/test_ID-123_abc",
expected: "test_ID-123_abc",
},
{
name: "invalid URI - no share path",
uri: "https://rise.articulate.com/",
hasError: true,
},
{
name: "invalid URI - wrong domain",
uri: "https://example.com/share/test123",
hasError: true,
},
{
name: "invalid URI - no share ID",
uri: "https://rise.articulate.com/share/",
hasError: true,
},
{
name: "empty URI",
uri: "",
hasError: true,
},
{
name: "malformed URI",
uri: "not-a-uri",
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parser.extractShareID(tt.uri)
if tt.hasError {
if err == nil {
t.Fatalf("Expected error for URI '%s', got nil", tt.uri)
}
} else {
if err != nil {
t.Fatalf("Expected no error for URI '%s', got: %v", tt.uri, err)
}
if result != tt.expected {
t.Errorf("Expected share ID '%s', got '%s'", tt.expected, result)
}
}
})
}
}
// TestBuildAPIURL tests the buildAPIURL method.
func TestBuildAPIURL(t *testing.T) {
parser := &ArticulateParser{
BaseURL: "https://rise.articulate.com",
}
tests := []struct {
name string
shareID string
expected string
}{
{
name: "standard share ID",
shareID: "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO",
},
{
name: "short share ID",
shareID: "abc123",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/abc123",
},
{
name: "share ID with special characters",
shareID: "test_ID-123_abc",
expected: "https://rise.articulate.com/api/rise-runtime/boot/share/test_ID-123_abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parser.buildAPIURL(tt.shareID)
if result != tt.expected {
t.Errorf("Expected URL '%s', got '%s'", tt.expected, result)
}
})
}
}
// TestBuildAPIURL_DifferentBaseURL tests buildAPIURL with different base URLs.
func TestBuildAPIURL_DifferentBaseURL(t *testing.T) {
parser := &ArticulateParser{
BaseURL: "https://custom.domain.com",
}
shareID := "test123"
expected := "https://custom.domain.com/api/rise-runtime/boot/share/test123"
result := parser.buildAPIURL(shareID)
if result != expected {
t.Errorf("Expected URL '%s', got '%s'", expected, result)
}
}
// BenchmarkExtractShareID benchmarks the extractShareID method.
func BenchmarkExtractShareID(b *testing.B) {
parser := &ArticulateParser{}
uri := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = parser.extractShareID(uri)
}
}
// BenchmarkBuildAPIURL benchmarks the buildAPIURL method.
func BenchmarkBuildAPIURL(b *testing.B) {
parser := &ArticulateParser{
BaseURL: "https://rise.articulate.com",
}
shareID := "N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = parser.buildAPIURL(shareID)
}
}

View File

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

26
main.go
View File

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

478
main_test.go Normal file
View File

@ -0,0 +1,478 @@
// Package main_test provides tests for the main package utility functions.
package main
import (
"bytes"
"fmt"
"io"
"log"
"os"
"strings"
"testing"
)
// TestIsURI tests the isURI function with various input scenarios.
func TestIsURI(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "valid HTTP URI",
input: "http://example.com",
expected: true,
},
{
name: "valid HTTPS URI",
input: "https://example.com",
expected: true,
},
{
name: "valid Articulate Rise URI",
input: "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/",
expected: true,
},
{
name: "local file path",
input: "C:\\Users\\test\\file.json",
expected: false,
},
{
name: "relative file path",
input: "./sample.json",
expected: false,
},
{
name: "filename only",
input: "sample.json",
expected: false,
},
{
name: "empty string",
input: "",
expected: false,
},
{
name: "short string",
input: "http",
expected: false,
},
{
name: "malformed URI",
input: "htp://example.com",
expected: false,
},
{
name: "FTP URI",
input: "ftp://example.com",
expected: false,
},
{
name: "HTTP with extra characters",
input: "xhttp://example.com",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isURI(tt.input)
if result != tt.expected {
t.Errorf("isURI(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
// TestJoinStrings tests the joinStrings function with various input scenarios.
func TestJoinStrings(t *testing.T) {
tests := []struct {
name string
strs []string
separator string
expected string
}{
{
name: "empty slice",
strs: []string{},
separator: ", ",
expected: "",
},
{
name: "single string",
strs: []string{"hello"},
separator: ", ",
expected: "hello",
},
{
name: "two strings with comma separator",
strs: []string{"markdown", "docx"},
separator: ", ",
expected: "markdown, docx",
},
{
name: "three strings with comma separator",
strs: []string{"markdown", "md", "docx"},
separator: ", ",
expected: "markdown, md, docx",
},
{
name: "multiple strings with pipe separator",
strs: []string{"option1", "option2", "option3"},
separator: " | ",
expected: "option1 | option2 | option3",
},
{
name: "strings with no separator",
strs: []string{"a", "b", "c"},
separator: "",
expected: "abc",
},
{
name: "strings with newline separator",
strs: []string{"line1", "line2", "line3"},
separator: "\n",
expected: "line1\nline2\nline3",
},
{
name: "empty strings in slice",
strs: []string{"", "middle", ""},
separator: "-",
expected: "-middle-",
},
{
name: "nil slice",
strs: nil,
separator: ", ",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinStrings(tt.strs, tt.separator)
if result != tt.expected {
t.Errorf("joinStrings(%v, %q) = %q, want %q", tt.strs, tt.separator, result, tt.expected)
}
})
}
}
// BenchmarkIsURI benchmarks the isURI function performance.
func BenchmarkIsURI(b *testing.B) {
testStr := "https://rise.articulate.com/share/N_APNg40Vr2CSH2xNz-ZLATM5kNviDIO#/"
b.ResetTimer()
for i := 0; i < b.N; i++ {
isURI(testStr)
}
}
// BenchmarkJoinStrings benchmarks the joinStrings function performance.
func BenchmarkJoinStrings(b *testing.B) {
strs := []string{"markdown", "md", "docx", "word", "pdf", "html"}
separator := ", "
b.ResetTimer()
for i := 0; i < b.N; i++ {
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.
}

View File

@ -531,6 +531,11 @@ try {
if ($Failed -gt 0) {
exit 1
}
# Clean up environment variables to avoid contaminating future builds
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
} finally {
Pop-Location
}

View File

@ -217,6 +217,14 @@ if [ "$SHOW_TARGETS" = true ]; then
exit 0
fi
# Validate Go installation
if ! command -v go >/dev/null 2>&1; then
echo "Error: Go is not installed or not in PATH"
echo "Please install Go from https://golang.org/dl/"
echo "Or if running on Windows, use the PowerShell script: scripts\\build.ps1"
exit 1
fi
# Validate entry point exists
if [ ! -f "$ENTRYPOINT" ]; then
echo "Error: Entry point file '$ENTRYPOINT' does not exist"
@ -315,7 +323,7 @@ for idx in "${!TARGETS[@]}"; do
fi
build_cmd+=("${GO_BUILD_FLAGS_ARRAY[@]}" -o "$OUTDIR/$BIN" "$ENTRYPOINT")
if CGO_ENABLED=1 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
if CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" "${build_cmd[@]}" 2>"$OUTDIR/$BIN.log"; then
update_status $((idx + 1)) '✔' "$BIN done"
rm -f "$OUTDIR/$BIN.log"
else
@ -356,3 +364,6 @@ if [ "$VERBOSE" = true ]; then
echo " ────────────────────────────────────────────────"
printf " Total: %d/%d successful, %s total size\n" "$success_count" "${#TARGETS[@]}" "$(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "${total_size} bytes")"
fi
# Clean up environment variables to avoid contaminating future builds
unset GOOS GOARCH CGO_ENABLED