diff --git a/go.mod b/go.mod index f6672c0..ee071a0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Life-USTC/CLI go 1.26.0 require ( - github.com/Life-USTC/client-core v0.3.0 + github.com/Life-USTC/client-core v0.4.0 github.com/PuerkitoBio/goquery v1.13.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 diff --git a/go.sum b/go.sum index bec693d..423732c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Life-USTC/client-core v0.3.0 h1:hpTnY1x6CIVQDIH0PoZdg7/9K+BEpaSss3BlaVdCHWs= -github.com/Life-USTC/client-core v0.3.0/go.mod h1:2SgYKZ3hbEFEigNGPdbfFS2m149evVw2t/mEjA/S/LM= +github.com/Life-USTC/client-core v0.4.0 h1:3Bif0F0mMt4EtievcIpGEeLHFnBnY4C2XP2byGwPySg= +github.com/Life-USTC/client-core v0.4.0/go.mod h1:2SgYKZ3hbEFEigNGPdbfFS2m149evVw2t/mEjA/S/LM= github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM= github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= diff --git a/internal/cmd/youngutil/youngutil.go b/internal/cmd/youngutil/youngutil.go index 0e7b8e9..f40d89c 100644 --- a/internal/cmd/youngutil/youngutil.go +++ b/internal/cmd/youngutil/youngutil.go @@ -12,6 +12,7 @@ import ( "github.com/Life-USTC/CLI/internal/cmd/cmdutil" "github.com/Life-USTC/client-core/calendar" + corepagination "github.com/Life-USTC/client-core/pagination" ) const ( @@ -101,8 +102,9 @@ func FetchAllPages( return nil, fmt.Errorf("page size must be positive") } + progress := corepagination.NewProgress(1) firstParams := cloneValues(params) - firstParams.Set("page", "1") + firstParams.Set("page", strconv.Itoa(progress.NextPage())) firstParams.Set("pageSize", strconv.Itoa(pageSize)) first, err := fetch(ctx, firstParams) if err != nil { @@ -131,11 +133,13 @@ func FetchAllPages( if len(rows) != 0 || totalPages > 1 { return nil, fmt.Errorf("%s returned inconsistent empty pagination", path) } + progress = progress.AfterPage(len(rows), false) } else { if totalPages < 1 { return nil, fmt.Errorf("%s returned no pages for %d results", path, total) } - for page := 2; page <= totalPages; page++ { + progress = progress.AfterPage(len(rows), totalPages > 1) + for page := progress.NextPage(); page != 0; page = progress.NextPage() { pageParams := cloneValues(params) pageParams.Set("page", strconv.Itoa(page)) pageParams.Set("pageSize", strconv.Itoa(pageSize)) @@ -160,10 +164,11 @@ func FetchAllPages( } } rows = append(rows, pageResult.Rows...) + progress = progress.AfterPage(len(pageResult.Rows), page < totalPages) } } - if len(rows) != total { - return nil, fmt.Errorf("%s returned %d of %d results", path, len(rows), total) + if progress.ReceivedCount() != total { + return nil, fmt.Errorf("%s returned %d of %d results", path, progress.ReceivedCount(), total) } result := cmdutil.NewListResult(first, key) @@ -171,7 +176,7 @@ func FetchAllPages( if m := cmdutil.AsMap(result.Raw); m != nil { if pg := cmdutil.AsMap(m["pagination"]); pg != nil { pg["page"] = 1 - resultPageSize := len(rows) + resultPageSize := progress.ReceivedCount() if resultPageSize == 0 { resultPageSize = pageSize } diff --git a/internal/cmd/youngutil/youngutil_test.go b/internal/cmd/youngutil/youngutil_test.go index 1ee712f..fb6fa35 100644 --- a/internal/cmd/youngutil/youngutil_test.go +++ b/internal/cmd/youngutil/youngutil_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" + "strings" "testing" "time" @@ -112,3 +114,91 @@ func TestFetchAllPagesRejectsIncompleteResponse(t *testing.T) { t.Fatal("FetchAllPages accepted an incomplete response") } } + +func TestFetchAllPagesPreservesPaginationPolicies(t *testing.T) { + page := func(total, pages int, ids ...string) any { + rows := make([]any, len(ids)) + for i, id := range ids { + rows[i] = map[string]any{"youngId": id} + } + return map[string]any{"data": rows, "pagination": map[string]any{"total": total, "totalPages": pages}} + } + tests := []struct { + name string + responses []any + wantPages []string + wantCount int + wantError string + }{ + {"empty zero pages", []any{page(0, 0)}, []string{"1"}, 0, ""}, + {"empty one page", []any{page(0, 1)}, []string{"1"}, 0, ""}, + {"empty middle page", []any{page(3, 3, "a"), page(3, 3), page(3, 3, "b", "c")}, []string{"1", "2", "3"}, 3, ""}, + {"empty total with rows", []any{page(0, 1, "a")}, []string{"1"}, 0, "inconsistent empty pagination"}, + {"empty total with more pages", []any{page(0, 2)}, []string{"1"}, 0, "inconsistent empty pagination"}, + {"nonempty total without pages", []any{page(1, 0)}, []string{"1"}, 0, "returned no pages"}, + {"page count limit", []any{page(1, 10001)}, []string{"1"}, 0, "invalid page count"}, + {"changed total", []any{page(3, 2, "a"), page(4, 2, "b")}, []string{"1", "2"}, 0, "changed pagination"}, + {"changed page count", []any{page(3, 2, "a"), page(3, 3, "b")}, []string{"1", "2"}, 0, "changed pagination"}, + {"duplicate first page", []any{page(3, 2, "a", "a")}, []string{"1"}, 0, "duplicate youngId"}, + {"duplicate later page", []any{page(3, 2, "a"), page(3, 2, "a", "b")}, []string{"1", "2"}, 0, "duplicate youngId"}, + {"empty ID", []any{page(1, 1, "")}, []string{"1"}, 0, "row without youngId"}, + {"oversized later page", []any{page(4, 2, "a"), page(4, 2, "b", "c", "d")}, []string{"1", "2"}, 0, "oversized page"}, + {"incomplete total", []any{page(3, 2, "a"), page(3, 2, "b")}, []string{"1", "2"}, 0, "returned 2 of 3 results"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requested []string + params := url.Values{"module": {"智"}, "active": {"false"}} + data, err := FetchAllPages(t.Context(), func(_ context.Context, query url.Values) (any, error) { + requested = append(requested, query.Get("page")) + if query.Get("pageSize") != "2" || query.Get("module") != "智" || query.Get("active") != "false" { + t.Fatalf("query = %v", query) + } + if len(requested) > len(tt.responses) { + t.Fatalf("unexpected extra page: %v", requested) + } + return tt.responses[len(requested)-1], nil + }, "/events", params, "data", 2, "youngId") + if !reflect.DeepEqual(requested, tt.wantPages) { + t.Fatalf("requested = %v, want %v", requested, tt.wantPages) + } + if params.Has("page") || params.Has("pageSize") { + t.Fatalf("caller parameters changed: %v", params) + } + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) || data != nil { + t.Fatalf("data = %#v, err = %v; want no partial data and %q", data, err, tt.wantError) + } + return + } + if err != nil { + t.Fatal(err) + } + result := data.(map[string]any) + if len(result["data"].([]any)) != tt.wantCount { + t.Fatalf("data = %#v", result) + } + pagination := result["pagination"].(map[string]any) + if pagination["page"] != 1 || pagination["totalPages"] != 1 || pagination["total"] != tt.wantCount { + t.Fatalf("pagination = %#v", pagination) + } + }) + } +} + +func TestFetchAllIfUnpagedKeepsExplicitSinglePage(t *testing.T) { + for _, params := range []url.Values{{"page": {"2"}}, {"pageSize": {"1"}}} { + calls := 0 + payload := map[string]any{"data": []any{}, "pagination": map[string]any{"page": 2, "total": 9, "totalPages": 9}} + data, err := FetchAllIfUnpaged(t.Context(), func(_ context.Context, query url.Values) (any, error) { + calls++ + if !reflect.DeepEqual(query, params) { + t.Fatalf("query = %v, want %v", query, params) + } + return payload, nil + }, "/events", params, "data", 2, "youngId") + if err != nil || calls != 1 || !reflect.DeepEqual(data, payload) { + t.Fatalf("calls = %d, data = %#v, err = %v", calls, data, err) + } + } +}