323 lines
12 KiB
Go
323 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestUnifiedUpdateChecksNewContentAndContinuesCatalog(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
pages int
|
|
savedOffset int
|
|
savedEnd bool
|
|
knownOffsets []int
|
|
endAt int
|
|
wantOffsets []int
|
|
wantSaved int
|
|
wantEnd bool
|
|
resumeSession bool
|
|
}{
|
|
{name: "first import runs one batch", pages: 2, endAt: -1, wantOffsets: []int{0, 18}, wantSaved: 36},
|
|
{name: "known head resumes saved tail", pages: 2, savedOffset: 90, knownOffsets: []int{0}, endAt: -1, wantOffsets: []int{0, 90, 108}, wantSaved: 126, resumeSession: true},
|
|
{name: "new head stops at overlap", pages: 4, savedOffset: 90, knownOffsets: []int{18}, endAt: -1, wantOffsets: []int{0, 18, 90, 108, 126, 144}, wantSaved: 162, resumeSession: true},
|
|
{name: "large update continues next unread page", pages: 4, savedOffset: 180, endAt: -1, wantOffsets: []int{0, 18, 36, 54, 72, 90, 108}, wantSaved: 126},
|
|
{name: "exhausted catalog still checks new content", pages: 4, savedOffset: 180, savedEnd: true, knownOffsets: []int{18}, endAt: -1, wantOffsets: []int{0, 18}, wantSaved: 180, wantEnd: true},
|
|
{name: "continuation reaches end", pages: 2, savedOffset: 90, knownOffsets: []int{0}, endAt: 90, wantOffsets: []int{0, 90}, wantSaved: 108, wantEnd: true, resumeSession: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
calls := map[string][]int{}
|
|
var known []Drama
|
|
for index := range hongguoAppGenres {
|
|
for _, offset := range test.knownOffsets {
|
|
known = append(known, Drama{ID: fmt.Sprintf("hongguo:%d", 790000+index*1000+offset), Source: sourceHongguo})
|
|
}
|
|
}
|
|
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
|
var payload struct {
|
|
Offset int `json:"offset"`
|
|
Session string `json:"session_id"`
|
|
Select struct {
|
|
Genre []string `json:"genre"`
|
|
} `json:"select_items"`
|
|
}
|
|
if request.URL.Path != "/reading/distribution/category/landpage/v/" || json.NewDecoder(request.Body).Decode(&payload) != nil || len(payload.Select.Genre) != 1 {
|
|
t.Errorf("unexpected catalog request: %s", request.URL.Path)
|
|
return rankingHTTPResponse(request, 400, ""), nil
|
|
}
|
|
genre := payload.Select.Genre[0]
|
|
index := -1
|
|
for position, candidate := range hongguoAppGenres {
|
|
if candidate.key == genre {
|
|
index = position
|
|
}
|
|
}
|
|
if index < 0 {
|
|
t.Errorf("unexpected genre: %s", genre)
|
|
return rankingHTTPResponse(request, 400, ""), nil
|
|
}
|
|
calls[genre] = append(calls[genre], payload.Offset)
|
|
if payload.Offset == 0 && payload.Session != "" {
|
|
t.Error("head check reused a tail session")
|
|
}
|
|
if test.resumeSession && payload.Offset == test.savedOffset && payload.Session != "saved-"+genre {
|
|
t.Error("continuation lost its saved session")
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"data": map[string]any{
|
|
"next_offset": payload.Offset + 18, "session_id": "fixture-" + genre, "has_more": payload.Offset != test.endAt,
|
|
"video_data": []any{map[string]any{
|
|
"series_id": fmt.Sprint(790000 + index*1000 + payload.Offset), "series_title": "文字目录样本",
|
|
"first_visible_time": "1773662280", "hot_score": "20", "series_play_cnt": "0",
|
|
}},
|
|
}})
|
|
return rankingHTTPResponse(request, 200, string(body)), nil
|
|
})
|
|
d.cfg.MaxPagesPerSort = test.pages
|
|
if test.savedOffset > 0 {
|
|
client := d.hongguoClient()
|
|
for _, genre := range hongguoAppGenres {
|
|
client.state.Feeds[genre.key] = hongguoCatalogCursor{
|
|
Offset: test.savedOffset, SessionID: "saved-" + genre.key, Initialized: true, Exhausted: test.savedEnd, UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
}
|
|
ctx := withKnownHongguoDramas(context.WithValue(context.Background(), libraryUpdateKey{}, true), known)
|
|
items, err := d.fetchHongguoAppCatalog(ctx)
|
|
if err != nil || len(items) != len(test.wantOffsets)*len(hongguoAppGenres) {
|
|
t.Fatalf("catalog result: %d items, %v", len(items), err)
|
|
}
|
|
for _, genre := range hongguoAppGenres {
|
|
if !reflect.DeepEqual(calls[genre.key], test.wantOffsets) {
|
|
t.Errorf("%s requested %v, want %v", genre.key, calls[genre.key], test.wantOffsets)
|
|
}
|
|
cursor := d.hongguoCatalogSnapshot().Feeds[genre.key]
|
|
if !cursor.Initialized || cursor.Offset != test.wantSaved || cursor.Exhausted != test.wantEnd {
|
|
t.Errorf("wrong checkpoint for %s: %+v", genre.key, cursor)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnifiedUpdatePreservesHistoryAcrossRestart(t *testing.T) {
|
|
calls := map[string][]int{}
|
|
transport := func(request *http.Request) (*http.Response, error) {
|
|
var payload struct {
|
|
Offset int `json:"offset"`
|
|
Scene string `json:"req_scene"`
|
|
}
|
|
if request.URL.Path != "/reading/distribution/category/landpage/v/" || json.NewDecoder(request.Body).Decode(&payload) != nil {
|
|
t.Errorf("unexpected request: %s", request.URL.Path)
|
|
return rankingHTTPResponse(request, 400, ""), nil
|
|
}
|
|
calls[payload.Scene] = append(calls[payload.Scene], payload.Offset)
|
|
index := 0
|
|
for position, genre := range hongguoAppGenres {
|
|
if genre.scene == payload.Scene {
|
|
index = position
|
|
}
|
|
}
|
|
body := fmt.Sprintf(`{"data":{"next_offset":%d,"has_more":%t,"session_id":"saved-session","video_data":[{"series_id":"%d","series_title":"文字样本","first_visible_time":"1773662280","hot_score":"20","series_play_cnt":"0"}]}}`, payload.Offset+18, payload.Offset == 0, 790000+index*1000+payload.Offset)
|
|
return rankingHTTPResponse(request, 200, body), nil
|
|
}
|
|
directory := t.TempDir()
|
|
for run, wantCount := range []int{3, 6, 6} {
|
|
d := rankingTestDownloader(t, transport)
|
|
d.cfg.dataDir, d.cfg.MaxPagesPerSort = directory, 1
|
|
items, err := d.RefreshDramas(context.WithValue(context.Background(), libraryUpdateKey{}, true), sourceHongguo)
|
|
if err != nil || len(items) != wantCount {
|
|
t.Fatalf("run %d lost history: %d items, %v", run, len(items), err)
|
|
}
|
|
cache, err := readLibraryCache(directory)
|
|
if err != nil || len(cache.Dramas) != wantCount || hongguoCatalogHasMore(cache.HongguoApp) != (run == 0) {
|
|
t.Fatalf("run %d did not persist data and cursor together: %v", run, err)
|
|
}
|
|
for _, drama := range cache.Dramas {
|
|
if drama.OnlineDate != "2026-03-16" || drama.Heat != "20" || drama.Views != "0" {
|
|
t.Fatal("update lost available sorting fields")
|
|
}
|
|
}
|
|
}
|
|
for scene, offsets := range calls {
|
|
if !reflect.DeepEqual(offsets, []int{0, 0, 18, 0}) {
|
|
t.Errorf("%s skipped or repeated a continuation: %v", scene, offsets)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnifiedUpdateReturnsImmediatelyAndDeduplicatesWork(t *testing.T) {
|
|
catalogStarted, catalogRelease := make(chan struct{}), make(chan struct{})
|
|
detailStarted, detailRelease := make(chan string, 1), make(chan struct{})
|
|
releaseCatalog := sync.OnceFunc(func() { close(catalogRelease) })
|
|
releaseDetail := sync.OnceFunc(func() { close(detailRelease) })
|
|
var catalogOnce sync.Once
|
|
var catalogCalls atomic.Int32
|
|
var detailMu sync.Mutex
|
|
var detailIDs []string
|
|
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
|
|
switch request.URL.Path {
|
|
case "/reading/distribution/category/landpage/v/":
|
|
catalogCalls.Add(1)
|
|
catalogOnce.Do(func() { close(catalogStarted) })
|
|
select {
|
|
case <-catalogRelease:
|
|
case <-request.Context().Done():
|
|
return nil, request.Context().Err()
|
|
}
|
|
return rankingHTTPResponse(request, 200, `{"data":{"next_offset":18,"has_more":false,"video_data":[{"series_id":"790000","series_title":"已完成的目录条目","first_visible_time":"1773662280","hot_score":"20","series_play_cnt":"0"}]}}`), nil
|
|
case "/detail":
|
|
if request.Context().Value(backgroundCatalogKey{}) != true {
|
|
t.Error("historical metadata did not use background priority")
|
|
}
|
|
id := request.URL.Query().Get("series_id")
|
|
detailMu.Lock()
|
|
detailIDs = append(detailIDs, id)
|
|
if len(detailIDs) == 1 {
|
|
detailStarted <- id
|
|
}
|
|
detailMu.Unlock()
|
|
select {
|
|
case <-detailRelease:
|
|
case <-request.Context().Done():
|
|
return nil, request.Context().Err()
|
|
}
|
|
return rankingHTTPResponse(request, 200, sortDetailFixture(id, "1773662280")), nil
|
|
default:
|
|
t.Errorf("unexpected resource: %s", request.URL.Path)
|
|
return rankingHTTPResponse(request, 404, ""), nil
|
|
}
|
|
})
|
|
d.cfg.MaxPagesPerSort = 1
|
|
for _, genre := range hongguoAppGenres {
|
|
d.hongguoClient().state.Feeds[genre.key] = hongguoCatalogCursor{Initialized: true, Exhausted: true, Offset: 18}
|
|
}
|
|
app := &UIApp{downloader: d, cfg: d.cfg, libraryAttempted: true, libraryApp: d.hongguoCatalogSnapshot(), librarySources: map[string]librarySourceState{}}
|
|
app.dramas = append(app.dramas, Drama{ID: "hongguo:790000", Source: sourceHongguo, Title: "已完成的目录条目", OnlineDate: "2026-03-16", Heat: "20", Views: "0", Cover: coverAddressFixture, SortMetadata: &sortMetadataState{Version: sortMetadataVersion}})
|
|
for index := 0; index < 60; index++ {
|
|
app.dramas = append(app.dramas, Drama{ID: fmt.Sprintf("hongguo:%d", 800000+index), Source: sourceHongguo, Title: "历史文字样本", Heat: "20", Views: "0", Cover: coverAddressFixture})
|
|
}
|
|
t.Cleanup(func() {
|
|
releaseCatalog()
|
|
releaseDetail()
|
|
app.mu.Lock()
|
|
done, cancel := app.libraryLoading, app.libraryCancel
|
|
app.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
if done != nil {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Error("catalog worker did not stop")
|
|
}
|
|
}
|
|
app.stopSortMetadata()
|
|
})
|
|
type snapshot struct {
|
|
Accepted bool `json:"updateAccepted"`
|
|
Loading bool `json:"loading"`
|
|
Data []Drama `json:"data"`
|
|
Metadata libraryMetadataProgress `json:"metadata"`
|
|
}
|
|
read := func(path string) snapshot {
|
|
t.Helper()
|
|
done := make(chan *httptest.ResponseRecorder, 1)
|
|
go func() {
|
|
writer := httptest.NewRecorder()
|
|
app.handleDramas(writer, httptest.NewRequest("GET", path, nil))
|
|
done <- writer
|
|
}()
|
|
select {
|
|
case writer := <-done:
|
|
var result snapshot
|
|
if writer.Code != 200 || json.Unmarshal(writer.Body.Bytes(), &result) != nil {
|
|
t.Fatalf("invalid snapshot: %d %s", writer.Code, writer.Body.String())
|
|
}
|
|
return result
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("library response waited for a stalled upstream request")
|
|
return snapshot{}
|
|
}
|
|
}
|
|
if result := read("/api/ui/dramas"); result.Loading || result.Metadata.Total != 0 || catalogCalls.Load() != 0 {
|
|
t.Fatal("ordinary cache read scheduled remote work")
|
|
}
|
|
path := "/api/ui/dramas?update=1&source=hongguo&priority=hongguo:800059"
|
|
result := read(path)
|
|
if !result.Accepted || !result.Loading || result.Metadata.Total != 40 || len(result.Data) != 61 {
|
|
t.Fatalf("update did not immediately return cache and bounded progress: %+v", result.Metadata)
|
|
}
|
|
select {
|
|
case <-catalogStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("update did not start the main catalog")
|
|
}
|
|
for _, request := range []string{path, path, "/api/ui/dramas?revision=1"} {
|
|
if result := read(request); result.Accepted || !result.Loading || result.Metadata.Total != 40 {
|
|
t.Fatal("repeated update or polling duplicated work")
|
|
}
|
|
}
|
|
select {
|
|
case <-detailStarted:
|
|
t.Fatal("metadata did not yield to the catalog")
|
|
default:
|
|
}
|
|
app.mu.Lock()
|
|
catalogDone := app.libraryLoading
|
|
app.mu.Unlock()
|
|
releaseCatalog()
|
|
select {
|
|
case <-catalogDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("catalog waited for optional metadata")
|
|
}
|
|
select {
|
|
case id := <-detailStarted:
|
|
if id != "800059" {
|
|
t.Fatalf("current filtered result was not prioritized: %s", id)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("historical metadata was not scheduled")
|
|
}
|
|
if result := read("/api/ui/dramas"); result.Loading || !result.Metadata.Running || result.Metadata.Checked != 0 || catalogCalls.Load() != 3 {
|
|
t.Fatal("optional metadata blocked the main response or catalog work was duplicated")
|
|
}
|
|
releaseDetail()
|
|
waitSortMetadataQueue(t, app)
|
|
result = read("/api/ui/dramas")
|
|
if result.Metadata.Checked != 40 || result.Metadata.Failed != 0 || result.Metadata.Running {
|
|
t.Fatalf("unexpected completion: %+v", result.Metadata)
|
|
}
|
|
untouched := 0
|
|
for _, drama := range result.Data {
|
|
if strings.HasPrefix(drama.ID, "hongguo:800") && drama.SortMetadata == nil {
|
|
untouched++
|
|
}
|
|
}
|
|
if untouched != 20 {
|
|
t.Fatalf("update migrated more than one historical batch: %d untouched", untouched)
|
|
}
|
|
detailMu.Lock()
|
|
defer detailMu.Unlock()
|
|
seen := map[string]bool{}
|
|
for _, id := range detailIDs {
|
|
if seen[id] {
|
|
t.Fatalf("duplicate historical request: %s", id)
|
|
}
|
|
seen[id] = true
|
|
}
|
|
if len(seen) != 40 {
|
|
t.Fatalf("unexpected detail request count: %d", len(seen))
|
|
}
|
|
}
|