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) }) } func TestBuildProgressSnapshot(t *testing.T) { t.Run("nil fields are omitted", func(t *testing.T) { pct := 0.5 ch := 3 req := SaveProgressRequest{ Percentage: &pct, Chapter: &ch, } snapshot := buildProgressSnapshot(req) assert.InDelta(t, 0.5, snapshot["percentage"], 0.001) assert.Equal(t, 3, snapshot["chapter"]) _, hasEpubcfi := snapshot["epubcfi"] assert.False(t, hasEpubcfi) }) t.Run("all fields present", func(t *testing.T) { pct := 0.75 cfi := "epubcfi(/6/4/2:10)" ch := 5 charOff := int64(10000) page := 150 tp := 200 req := SaveProgressRequest{ Percentage: &pct, Epubcfi: &cfi, Chapter: &ch, CharacterOffset: &charOff, CurrentPage: &page, TotalPages: &tp, } snapshot := buildProgressSnapshot(req) assert.InDelta(t, 0.75, snapshot["percentage"], 0.001) assert.Equal(t, "epubcfi(/6/4/2:10)", snapshot["epubcfi"]) assert.Equal(t, 5, snapshot["chapter"]) assert.Equal(t, int64(10000), snapshot["character"]) assert.Equal(t, 150, snapshot["page"]) assert.Equal(t, 200, snapshot["total_pages"]) }) }