mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-01-16 11:02:10 +01:00
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
This commit is contained in:
25
.github/workflows/autofix.yml
vendored
Normal file
25
.github/workflows/autofix.yml
vendored
Normal 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,6 +31,7 @@ go.work
|
|||||||
|
|
||||||
# Local test files
|
# Local test files
|
||||||
output/
|
output/
|
||||||
|
outputs/
|
||||||
articulate-sample.json
|
articulate-sample.json
|
||||||
test-output.*
|
test-output.*
|
||||||
go-os-arch-matrix.csv
|
go-os-arch-matrix.csv
|
||||||
|
|||||||
@ -48,6 +48,8 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
|||||||
return NewMarkdownExporter(f.htmlCleaner), nil
|
return NewMarkdownExporter(f.htmlCleaner), nil
|
||||||
case "docx", "word":
|
case "docx", "word":
|
||||||
return NewDocxExporter(f.htmlCleaner), nil
|
return NewDocxExporter(f.htmlCleaner), nil
|
||||||
|
case "html", "htm":
|
||||||
|
return NewHTMLExporter(f.htmlCleaner), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported export format: %s", format)
|
return nil, fmt.Errorf("unsupported export format: %s", format)
|
||||||
}
|
}
|
||||||
@ -59,5 +61,5 @@ func (f *Factory) CreateExporter(format string) (interfaces.Exporter, error) {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - A string slice containing all supported format names
|
// - A string slice containing all supported format names
|
||||||
func (f *Factory) GetSupportedFormats() []string {
|
func (f *Factory) GetSupportedFormats() []string {
|
||||||
return []string{"markdown", "md", "docx", "word"}
|
return []string{"markdown", "md", "docx", "word", "html", "htm"}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,20 @@ func TestFactory_CreateExporter(t *testing.T) {
|
|||||||
expectedFormat: "docx",
|
expectedFormat: "docx",
|
||||||
shouldError: false,
|
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",
|
name: "unsupported format",
|
||||||
format: "pdf",
|
format: "pdf",
|
||||||
@ -139,6 +153,12 @@ func TestFactory_CreateExporter_CaseInsensitive(t *testing.T) {
|
|||||||
{"WORD", "docx"},
|
{"WORD", "docx"},
|
||||||
{"Word", "docx"},
|
{"Word", "docx"},
|
||||||
{"WoRd", "docx"},
|
{"WoRd", "docx"},
|
||||||
|
{"HTML", "html"},
|
||||||
|
{"Html", "html"},
|
||||||
|
{"HtMl", "html"},
|
||||||
|
{"HTM", "html"},
|
||||||
|
{"Htm", "html"},
|
||||||
|
{"HtM", "html"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
@ -168,7 +188,6 @@ func TestFactory_CreateExporter_ErrorMessages(t *testing.T) {
|
|||||||
|
|
||||||
testCases := []string{
|
testCases := []string{
|
||||||
"pdf",
|
"pdf",
|
||||||
"html",
|
|
||||||
"txt",
|
"txt",
|
||||||
"json",
|
"json",
|
||||||
"xml",
|
"xml",
|
||||||
@ -213,7 +232,7 @@ func TestFactory_GetSupportedFormats(t *testing.T) {
|
|||||||
t.Fatal("GetSupportedFormats() returned nil")
|
t.Fatal("GetSupportedFormats() returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{"markdown", "md", "docx", "word"}
|
expected := []string{"markdown", "md", "docx", "word", "html", "htm"}
|
||||||
|
|
||||||
// Sort both slices for comparison
|
// Sort both slices for comparison
|
||||||
sort.Strings(formats)
|
sort.Strings(formats)
|
||||||
@ -321,6 +340,21 @@ func TestFactory_HTMLCleanerPropagation(t *testing.T) {
|
|||||||
if docxImpl.htmlCleaner == nil {
|
if docxImpl.htmlCleaner == nil {
|
||||||
t.Error("HTMLCleaner should be propagated to DocxExporter")
|
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.
|
// TestFactory_MultipleExporterCreation tests creating multiple exporters of same type.
|
||||||
|
|||||||
476
internal/exporters/html.go
Normal file
476
internal/exporters/html.go
Normal 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")
|
||||||
|
}
|
||||||
927
internal/exporters/html_test.go
Normal file
927
internal/exporters/html_test.go
Normal 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 & entities</h1>",
|
||||||
|
Paragraph: "<p>Paragraph with <code> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user