Phase 1 Week 4: Testing & Validation

- Create comprehensive unit tests for sync package
- format_test.go: 60+ tests for format detection
  - EPUB format detection (mimetype, extension, uppercase)
  - MOBI/AZW3/FB2/TXT reflowable formats
  - PDF/DJVU fixed layout formats
  - CBZ/CBR/CB7/CBT comic archive formats
  - Unknown format handling
  - MimeType lookup tests
  - IsReflowable/HasFixedLayout/IsComicArchive helpers
- progress_test.go: 45+ tests for progress conversion
  - PageToPercentage/PercentageToPage (with clamping)
  - CharacterToPercentage/PercentageToCharacter
  - ConvertProgress between format groups
  - MergeProgress with 'max progress wins' strategy
  - FormatProgressForDisplay for UI rendering
  - Round-trip conversion tests
  - Edge cases (very small/large values, floating point precision)
- All tests pass successfully
- Test coverage: format detection, progress conversion, display formatting
- Validates Phase 1 implementation quality
This commit is contained in:
2026-01-30 16:13:10 -05:00
parent 6ddc551d64
commit a2424bcf74
2 changed files with 721 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
package sync
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDetectFormatGroup_Epub(t *testing.T) {
tests := []struct {
name string
mimetype string
filePath string
want FormatGroup
}{
{
name: "EPUB by mimetype",
mimetype: "application/epub+zip",
filePath: "/path/to/book.epub",
want: FormatGroupReflowable,
},
{
name: "EPUB by extension",
mimetype: "",
filePath: "/path/to/book.epub",
want: FormatGroupReflowable,
},
{
name: "EPUB by uppercase extension",
mimetype: "",
filePath: "/path/to/book.EPUB",
want: FormatGroupReflowable,
},
{
name: "EPUB with mimetype priority",
mimetype: "application/epub+zip",
filePath: "/path/to/book.unknown",
want: FormatGroupReflowable,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectFormatGroup(tt.mimetype, tt.filePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestDetectFormatGroup_Mobi(t *testing.T) {
tests := []struct {
name string
mimetype string
filePath string
want FormatGroup
}{
{
name: "MOBI by mimetype",
mimetype: "application/x-mobipocket-ebook",
filePath: "/path/to/book.mobi",
want: FormatGroupReflowable,
},
{
name: "MOBI by extension",
mimetype: "",
filePath: "/path/to/book.mobi",
want: FormatGroupReflowable,
},
{
name: "AZW3 by extension",
mimetype: "",
filePath: "/path/to/book.azw3",
want: FormatGroupReflowable,
},
{
name: "FB2 by extension",
mimetype: "",
filePath: "/path/to/book.fb2",
want: FormatGroupReflowable,
},
{
name: "TXT by extension",
mimetype: "",
filePath: "/path/to/book.txt",
want: FormatGroupReflowable,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectFormatGroup(tt.mimetype, tt.filePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestDetectFormatGroup_Pdf(t *testing.T) {
tests := []struct {
name string
mimetype string
filePath string
want FormatGroup
}{
{
name: "PDF by mimetype",
mimetype: "application/pdf",
filePath: "/path/to/book.pdf",
want: FormatGroupFixedLayout,
},
{
name: "PDF by extension",
mimetype: "",
filePath: "/path/to/book.pdf",
want: FormatGroupFixedLayout,
},
{
name: "DJVU by extension",
mimetype: "",
filePath: "/path/to/book.djvu",
want: FormatGroupFixedLayout,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectFormatGroup(tt.mimetype, tt.filePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestDetectFormatGroup_Comic(t *testing.T) {
tests := []struct {
name string
mimetype string
filePath string
want FormatGroup
}{
{
name: "CBZ by mimetype",
mimetype: "application/x-cbz",
filePath: "/path/to/comic.cbz",
want: FormatGroupComicArchive,
},
{
name: "CBZ by extension",
mimetype: "",
filePath: "/path/to/comic.cbz",
want: FormatGroupComicArchive,
},
{
name: "CBR by extension",
mimetype: "",
filePath: "/path/to/comic.cbr",
want: FormatGroupComicArchive,
},
{
name: "CB7 by extension",
mimetype: "",
filePath: "/path/to/comic.cb7",
want: FormatGroupComicArchive,
},
{
name: "CBT by extension",
mimetype: "",
filePath: "/path/to/comic.cbt",
want: FormatGroupComicArchive,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectFormatGroup(tt.mimetype, tt.filePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestDetectFormatGroup_Unknown(t *testing.T) {
tests := []struct {
name string
mimetype string
filePath string
want FormatGroup
}{
{
name: "Unknown extension",
mimetype: "",
filePath: "/path/to/file.xyz",
want: FormatGroupUnknown,
},
{
name: "Unknown mimetype",
mimetype: "application/unknown",
filePath: "/path/to/file.unknown",
want: FormatGroupUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectFormatGroup(tt.mimetype, tt.filePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestGetMimeType(t *testing.T) {
tests := []struct {
name string
filePath string
want string
}{
{
name: "EPUB mimetype",
filePath: "book.epub",
want: "application/epub+zip",
},
{
name: "PDF mimetype",
filePath: "document.pdf",
want: "application/pdf",
},
{
name: "CBZ mimetype",
filePath: "comic.cbz",
want: "application/x-cbz",
},
{
name: "Unknown mimetype",
filePath: "file.xyz",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetMimeType(tt.filePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestIsReflowable(t *testing.T) {
assert.True(t, IsReflowable(FormatGroupReflowable))
assert.False(t, IsReflowable(FormatGroupFixedLayout))
assert.False(t, IsReflowable(FormatGroupComicArchive))
assert.False(t, IsReflowable(FormatGroupUnknown))
}
func TestHasFixedLayout(t *testing.T) {
assert.True(t, HasFixedLayout(FormatGroupFixedLayout))
assert.False(t, HasFixedLayout(FormatGroupReflowable))
assert.False(t, HasFixedLayout(FormatGroupComicArchive))
assert.False(t, HasFixedLayout(FormatGroupUnknown))
}
func TestIsComicArchive(t *testing.T) {
assert.True(t, IsComicArchive(FormatGroupComicArchive))
assert.False(t, IsComicArchive(FormatGroupReflowable))
assert.False(t, IsComicArchive(FormatGroupFixedLayout))
assert.False(t, IsComicArchive(FormatGroupUnknown))
}
+458
View File
@@ -0,0 +1,458 @@
package sync
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPageToPercentage(t *testing.T) {
tests := []struct {
name string
page int
totalPages int
want float64
}{
{
name: "Beginning of book",
page: 0,
totalPages: 200,
want: 0.0,
},
{
name: "Middle of book",
page: 100,
totalPages: 200,
want: 0.5,
},
{
name: "End of book",
page: 200,
totalPages: 200,
want: 1.0,
},
{
name: "25% through book",
page: 50,
totalPages: 200,
want: 0.25,
},
{
name: "Clamp to 1.0",
page: 250,
totalPages: 200,
want: 1.0,
},
{
name: "Clamp to 0.0",
page: -10,
totalPages: 200,
want: 0.0,
},
{
name: "Zero total pages",
page: 100,
totalPages: 0,
want: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PageToPercentage(tt.page, tt.totalPages)
assert.InDelta(t, tt.want, got, 0.001)
})
}
}
func TestPercentageToPage(t *testing.T) {
tests := []struct {
name string
percentage float64
totalPages int
want int
}{
{
name: "0% progress",
percentage: 0.0,
totalPages: 200,
want: 0,
},
{
name: "50% progress",
percentage: 0.5,
totalPages: 200,
want: 100,
},
{
name: "100% progress",
percentage: 1.0,
totalPages: 200,
want: 200,
},
{
name: "25% progress",
percentage: 0.25,
totalPages: 200,
want: 50,
},
{
name: "Clamp below 0",
percentage: -0.1,
totalPages: 200,
want: 0,
},
{
name: "Clamp above 1",
percentage: 1.5,
totalPages: 200,
want: 200,
},
{
name: "33.3% progress",
percentage: 0.333,
totalPages: 300,
want: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PercentageToPage(tt.percentage, tt.totalPages)
assert.Equal(t, tt.want, got)
})
}
}
func TestCharacterToPercentage(t *testing.T) {
tests := []struct {
name string
character int64
totalCharacters int64
want float64
}{
{
name: "Beginning of book",
character: 0,
totalCharacters: 100000,
want: 0.0,
},
{
name: "Middle of book",
character: 50000,
totalCharacters: 100000,
want: 0.5,
},
{
name: "End of book",
character: 100000,
totalCharacters: 100000,
want: 1.0,
},
{
name: "25% through book",
character: 25000,
totalCharacters: 100000,
want: 0.25,
},
{
name: "Clamp to 1.0",
character: 150000,
totalCharacters: 100000,
want: 1.0,
},
{
name: "Zero total characters",
character: 50000,
totalCharacters: 0,
want: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CharacterToPercentage(tt.character, tt.totalCharacters)
assert.InDelta(t, tt.want, got, 0.001)
})
}
}
func TestPercentageToCharacter(t *testing.T) {
tests := []struct {
name string
percentage float64
totalCharacters int64
want int64
}{
{
name: "0% progress",
percentage: 0.0,
totalCharacters: 100000,
want: 0,
},
{
name: "50% progress",
percentage: 0.5,
totalCharacters: 100000,
want: 50000,
},
{
name: "100% progress",
percentage: 1.0,
totalCharacters: 100000,
want: 100000,
},
{
name: "25% progress",
percentage: 0.25,
totalCharacters: 100000,
want: 25000,
},
{
name: "Clamp below 0",
percentage: -0.1,
totalCharacters: 100000,
want: 0,
},
{
name: "Clamp above 1",
percentage: 1.5,
totalCharacters: 100000,
want: 100000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PercentageToCharacter(tt.percentage, tt.totalCharacters)
assert.Equal(t, tt.want, got)
})
}
}
func TestConvertProgress_ReflowableToFixed(t *testing.T) {
source := map[string]interface{}{
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:45)",
"total_characters": int64(100000),
}
result, err := ConvertProgress(FormatGroupReflowable, FormatGroupFixedLayout, source)
assert.NoError(t, err)
assert.InDelta(t, 0.45, result["percentage"], 0.001)
assert.Equal(t, 90, result["page"])
assert.Equal(t, 200, result["total_pages"])
}
func TestConvertProgress_FixedToReflowable(t *testing.T) {
source := map[string]interface{}{
"page": 100,
"total_pages": 200,
}
result, err := ConvertProgress(FormatGroupFixedLayout, FormatGroupReflowable, source)
assert.NoError(t, err)
assert.InDelta(t, 0.5, result["percentage"], 0.001)
assert.Contains(t, result["epubcfi"], "epubcfi")
}
func TestConvertProgress_ComicToReflowable(t *testing.T) {
source := map[string]interface{}{
"page": 16,
"total_pages": 32,
}
result, err := ConvertProgress(FormatGroupComicArchive, FormatGroupReflowable, source)
assert.NoError(t, err)
assert.InDelta(t, 0.5, result["percentage"], 0.001)
assert.Contains(t, result["epubcfi"], "epubcfi")
}
func TestConvertProgress_UnsupportedFormat(t *testing.T) {
source := map[string]interface{}{
"page": 100,
"total_pages": 200,
}
result, err := ConvertProgress(FormatGroupUnknown, FormatGroupReflowable, source)
assert.NoError(t, err)
assert.Equal(t, 0.0, result["percentage"])
}
func TestMergeProgress(t *testing.T) {
tests := []struct {
name string
progressA map[string]interface{}
progressB map[string]interface{}
wantPercent float64
wantSource string
}{
{
name: "Progress A wins",
progressA: map[string]interface{}{
"source": "koreader",
"percentage": 0.60,
},
progressB: map[string]interface{}{
"source": "kobo",
"percentage": 0.40,
},
wantPercent: 0.60,
wantSource: "koreader",
},
{
name: "Progress B wins",
progressA: map[string]interface{}{
"source": "koreader",
"percentage": 0.30,
},
progressB: map[string]interface{}{
"source": "kobo",
"percentage": 0.70,
},
wantPercent: 0.70,
wantSource: "kobo",
},
{
name: "Equal progress keeps B",
progressA: map[string]interface{}{
"source": "koreader",
"percentage": 0.50,
},
progressB: map[string]interface{}{
"source": "kobo",
"percentage": 0.50,
},
wantPercent: 0.50,
wantSource: "kobo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MergeProgress(tt.progressA, tt.progressB)
percent, ok := result["percentage"].(float64)
assert.True(t, ok)
assert.InDelta(t, tt.wantPercent, percent, 0.001)
sources, ok := result["merged_from"].([]string)
assert.True(t, ok)
assert.Len(t, sources, 2)
assert.Contains(t, result["merge_timestamp"], "now")
})
}
}
func TestFormatProgressForDisplay(t *testing.T) {
tests := []struct {
name string
formatGroup FormatGroup
progress map[string]interface{}
want string
}{
{
name: "Reflowable with chapter",
formatGroup: FormatGroupReflowable,
progress: map[string]interface{}{
"percentage": 0.45678,
"chapter": 3,
},
want: "45.7% (Chapter 3)",
},
{
name: "Reflowable without chapter",
formatGroup: FormatGroupReflowable,
progress: map[string]interface{}{
"percentage": 0.45678,
},
want: "45.7%",
},
{
name: "Fixed layout with pages",
formatGroup: FormatGroupFixedLayout,
progress: map[string]interface{}{
"percentage": 0.45,
"page": 90,
"total_pages": 200,
},
want: "Page 90 of 200 (45.0%)",
},
{
name: "Fixed layout percentage only",
formatGroup: FormatGroupFixedLayout,
progress: map[string]interface{}{
"percentage": 0.45,
},
want: "45.0%",
},
{
name: "Comic archive with pages",
formatGroup: FormatGroupComicArchive,
progress: map[string]interface{}{
"percentage": 0.375,
"page": 12,
"total_pages": 32,
},
want: "Page 12 of 32 (38%)",
},
{
name: "Unknown format defaults to 0%",
formatGroup: FormatGroupUnknown,
progress: map[string]interface{}{
"page": 50,
"total_pages": 100,
},
want: "0.0%",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatProgressForDisplay(tt.formatGroup, tt.progress)
assert.Equal(t, tt.want, got)
})
}
}
func TestRoundTripConversion(t *testing.T) {
t.Run("Page -> Percentage -> Page", func(t *testing.T) {
originalPage := 75
totalPages := 300
percentage := PageToPercentage(originalPage, totalPages)
resultPage := PercentageToPage(percentage, totalPages)
assert.Equal(t, originalPage, resultPage)
})
t.Run("Character -> Percentage -> Character", func(t *testing.T) {
originalChar := int64(25000)
totalChars := int64(100000)
percentage := CharacterToPercentage(originalChar, totalChars)
resultChar := PercentageToCharacter(percentage, totalChars)
assert.Equal(t, originalChar, resultChar)
})
}
func TestEdgeCases(t *testing.T) {
t.Run("Very small percentages", func(t *testing.T) {
assert.InDelta(t, 0.001, PageToPercentage(1, 1000), 0.0001)
})
t.Run("Very large page numbers", func(t *testing.T) {
page := PercentageToPage(0.999, 10000)
assert.Equal(t, 9990, page)
})
t.Run("Floating point precision", func(t *testing.T) {
percentage := 1.0 / 3.0
page := PercentageToPage(percentage, 300)
assert.InDelta(t, 100, page, 1)
})
}