mirror of
https://github.com/kjanat/articulate-parser.git
synced 2026-10-13 02:15:27 +02:00
refactor: comprehensive code quality improvements
- Add logger to all exporters for consistent error handling - Add Course.Validate() method with ValidationError type - Create shared test fixtures in internal/testdata - Refactor config passing: NewArticulateParser now takes *config.Config - Add NewLoggerFromConfig() factory for logger creation - Add context cancellation with signal handling (SIGINT/SIGTERM) - Add godoc examples for loggers and exporters - Add HTML exporter error path tests - Add fuzz tests for parser and HTMLCleaner - Fix markdown output: trim whitespace, collapse blank lines - Remove test output files from repo
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
// These structures closely match the JSON format used by Articulate Rise.
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Course represents the top-level structure of an Articulate Rise course.
|
||||
// It contains metadata and the actual course content.
|
||||
type Course struct {
|
||||
@@ -53,3 +55,46 @@ type LabelSet struct {
|
||||
// Labels is a mapping of label keys to their customized values
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
// Validate checks whether the Course structure contains valid data.
|
||||
// It returns an error describing the first validation failure found,
|
||||
// or nil if the course is valid.
|
||||
//
|
||||
// Validation rules:
|
||||
// - ShareID must not be empty
|
||||
// - Course.ID must not be empty
|
||||
// - Course.Title must not be empty
|
||||
// - Each Lesson must have a non-empty ID and Title
|
||||
func (c *Course) Validate() error {
|
||||
if c.ShareID == "" {
|
||||
return &ValidationError{Field: "ShareID", Message: "share ID is required"}
|
||||
}
|
||||
if c.Course.ID == "" {
|
||||
return &ValidationError{Field: "Course.ID", Message: "course ID is required"}
|
||||
}
|
||||
if c.Course.Title == "" {
|
||||
return &ValidationError{Field: "Course.Title", Message: "course title is required"}
|
||||
}
|
||||
for i, lesson := range c.Course.Lessons {
|
||||
if lesson.ID == "" {
|
||||
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty ID", i)}
|
||||
}
|
||||
if lesson.Title == "" {
|
||||
return &ValidationError{Field: "Course.Lessons", Message: fmt.Sprintf("lesson at index %d has empty title", i)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationError represents a validation failure for a specific field.
|
||||
type ValidationError struct {
|
||||
// Field is the name of the field that failed validation
|
||||
Field string
|
||||
// Message describes the validation failure
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error implements the error interface for ValidationError.
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -664,6 +665,161 @@ func BenchmarkCourse_JSONUnmarshal(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCourse_Validate tests the Course.Validate method.
|
||||
func TestCourse_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
course Course
|
||||
wantErr bool
|
||||
errField string
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid course",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "lesson-1", Title: "First Lesson"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid course with no lessons",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing share ID",
|
||||
course: Course{
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "ShareID",
|
||||
errSubstr: "share ID is required",
|
||||
},
|
||||
{
|
||||
name: "missing course ID",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
Title: "Test Course",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.ID",
|
||||
errSubstr: "course ID is required",
|
||||
},
|
||||
{
|
||||
name: "missing course title",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Title",
|
||||
errSubstr: "course title is required",
|
||||
},
|
||||
{
|
||||
name: "lesson with empty ID",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "", Title: "First Lesson"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Lessons",
|
||||
errSubstr: "lesson at index 0 has empty ID",
|
||||
},
|
||||
{
|
||||
name: "lesson with empty title",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "lesson-1", Title: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Lessons",
|
||||
errSubstr: "lesson at index 0 has empty title",
|
||||
},
|
||||
{
|
||||
name: "second lesson invalid",
|
||||
course: Course{
|
||||
ShareID: "test-share-id",
|
||||
Course: CourseInfo{
|
||||
ID: "course-123",
|
||||
Title: "Test Course",
|
||||
Lessons: []Lesson{
|
||||
{ID: "lesson-1", Title: "First Lesson"},
|
||||
{ID: "lesson-2", Title: ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errField: "Course.Lessons",
|
||||
errSubstr: "lesson at index 1 has empty title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.course.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Course.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
var valErr *ValidationError
|
||||
if ok := errors.As(err, &valErr); !ok {
|
||||
t.Errorf("Expected ValidationError, got %T", err)
|
||||
return
|
||||
}
|
||||
if valErr.Field != tt.errField {
|
||||
t.Errorf("ValidationError.Field = %q, want %q", valErr.Field, tt.errField)
|
||||
}
|
||||
if valErr.Message != tt.errSubstr {
|
||||
t.Errorf("ValidationError.Message = %q, want %q", valErr.Message, tt.errSubstr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationError_Error tests the ValidationError.Error method.
|
||||
func TestValidationError_Error(t *testing.T) {
|
||||
err := &ValidationError{Field: "TestField", Message: "test message"}
|
||||
expected := "validation error on TestField: test message"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
// compareMaps compares two any values that should be maps.
|
||||
func compareMaps(original, unmarshaled any) bool {
|
||||
origMap, origOk := original.(map[string]any)
|
||||
|
||||
Reference in New Issue
Block a user