feat(app): add internal components batch 5

This commit is contained in:
吴璨
2026-09-30 10:20:38 +08:00
parent 4ec1f85bbc
commit 5bd126e026
10 changed files with 1956 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func sortDetailFixture(id, stamp string) string {
return `<script>window._ROUTER_DATA={"loaderData":{"detail_page":{"seriesDetail":{"series_id":"` + id + `","series_name":"测试剧","first_visible_time":"` + stamp + `","create_time":"1745906791"}}}}</script>`
}
func TestSortDetailDatesAndIdentity(t *testing.T) {
id := "7615465407347952664"
drama, err := parseHongguoSortDetail(sortDetailFixture(id, "1773662280"), id)
if err != nil || drama.OnlineDate != "2026-03-16" {
t.Fatalf("first-visible date: %q, %v", drama.OnlineDate, err)
}
if _, err = parseHongguoSortDetail(sortDetailFixture("7498612570866076734", "1773662280"), id); err == nil {
t.Fatal("accepted another drama's time")
}
drama, err = parseHongguoSortDetail(sortDetailFixture(id, "0"), id)
if err != nil || drama.OnlineDate != "" {
t.Fatal("creation time must not replace missing first-visible time")
}
for _, value := range []string{"", "0", "0001-01-01", "1970-01-01", "2101-01-01", "2026-02-31", "not a date"} {
if got := providerReleaseDate(value); got != "" {
t.Errorf("accepted date %q", value)
}
}
if normalizeViews("10019") != "10019次播放" {
t.Fatal("play counts must retain precision")
}
}
func TestSortMetadataSelectionAndResume(t *testing.T) {
now := time.Now()
var rows []Drama
for index := 0; index < 60; index++ {
rows = append(rows, Drama{ID: fmt.Sprintf("hongguo:%019d", index+1), Source: sourceHongguo})
}
rows[1].SortMetadata = &sortMetadataState{Version: sortMetadataVersion, CheckedAt: now, CoverChecked: true}
rows[2].SortMetadata = &sortMetadataState{CheckedAt: now}
rows = append(rows, Drama{ID: "huangdou:other", Source: "huangdou"})
batch := selectSortMetadataBatch(rows, sourceHongguo, []string{rows[59].ID, rows[59].ID, rows[2].ID, "huangdou:other"}, now)
if len(batch) != sortMetadataBatchSize || batch[0].ID != rows[59].ID {
t.Fatal("did not prioritize filtered entries within the batch limit")
}
for _, row := range batch {
if row.ID == rows[1].ID || row.ID == rows[2].ID || row.Source != sourceHongguo {
t.Fatal("selected completed, cooling-down or other-source entries")
}
}
if got := selectSortMetadataBatch(rows, "huangdou", nil, now); len(got) != 0 {
t.Fatal("source filter was not respected")
}
if remaining := sortMetadataRemaining(rows); remaining["hongguo"] != 59 || remaining["huangdou"] != 0 || remaining[""] != 59 {
t.Fatal(remaining)
}
}
func TestManualBackfillPersistsPartialResultsAndContinuesAfterCatalogEnd(t *testing.T) {
first, bad, unknown := "7615465407347952664", "7498612570866076734", "7677914379060251673"
var calls atomic.Int32
var fail atomic.Bool
fail.Store(true)
d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) {
calls.Add(1)
switch request.URL.Path {
case "/detail":
id := request.URL.Query().Get("series_id")
stamp := "1773662280"
if id == unknown {
stamp = "0"
}
return rankingHTTPResponse(request, 200, sortDetailFixture(id, stamp)), nil
case "/novel/player/video_detail/v1/":
var body struct {
ID string `json:"series_id"`
}
json.NewDecoder(request.Body).Decode(&body)
if body.ID == bad && fail.Load() {
return rankingHTTPResponse(request, 503, "temporary failure"), nil
}
return rankingHTTPResponse(request, 200, fmt.Sprintf(`{"data":{"video_data":{"series_id_str":%q,"series_title":"测试剧","hot_score":20019,"series_play_cnt":10019}}}`, body.ID)), nil
default:
t.Errorf("backfill requested a non-detail resource: %s", request.URL.Path)
return rankingHTTPResponse(request, 404, ""), nil
}
})
client := d.hongguoClient()
client.mu.Lock()
for _, genre := range hongguoAppGenres {
client.state.Feeds[genre.key] = hongguoCatalogCursor{Initialized: true, Exhausted: true}
}
client.mu.Unlock()
a := &UIApp{downloader: d, cfg: d.cfg, libraryAttempted: true, librarySources: map[string]librarySourceState{}, libraryApp: d.hongguoCatalogSnapshot(), dramas: []Drama{
{ID: "hongguo:" + first, Source: sourceHongguo, Title: "原有标题", Views: "0"},
{ID: "hongguo:" + bad, Source: sourceHongguo, Title: "暂时失败", Heat: "800"},
{ID: "hongguo:" + unknown, Source: sourceHongguo, Title: "站点日期未知"},
}}
t.Cleanup(a.stopSortMetadata)
plain := httptest.NewRecorder()
a.handleDramas(plain, httptest.NewRequest("GET", "/api/ui/dramas", nil))
if calls.Load() != 0 {
t.Fatal("ordinary library reads must not backfill")
}
before := a.librarySnapshotLocked(0)
if before["hasMoreBySource"].(map[string]bool)[sourceHongguo] != true {
t.Fatal("backfill must remain available after the catalog is exhausted")
}
writer := httptest.NewRecorder()
a.handleDramas(writer, httptest.NewRequest("GET", "/api/ui/dramas?more=1&source=hongguo&priority=hongguo:"+first, nil))
if writer.Code != 200 {
t.Fatal(writer.Body.String())
}
waitSortMetadataQueue(t, a)
a.mu.Lock()
rows := append([]Drama(nil), a.dramas...)
progress := a.libraryMetadata
a.mu.Unlock()
if progress.Checked != 3 || progress.Failed != 1 {
t.Fatalf("unexpected progress: %+v", progress)
}
for _, row := range rows {
switch row.ID {
case "hongguo:" + first:
if row.OnlineDate != "2026-03-16" || row.Heat != "20019" || row.Views != "10019次播放" || row.SortMetadata.Version != sortMetadataVersion {
t.Fatal("fresh sorting fields were not merged")
}
case "hongguo:" + bad:
if row.OnlineDate == "" || row.Heat != "800" || row.SortMetadata.Version != 0 {
t.Fatal("partial success must survive a failed metrics request")
}
case "hongguo:" + unknown:
if row.OnlineDate != "" || row.SortMetadata.Version != sortMetadataVersion {
t.Fatal("a valid response with no date must not be retried forever or invent a date")
}
}
}
saved, err := readLibraryCache(d.cfg.dataDirectory())
if err != nil {
t.Fatal(err)
}
if batch := selectSortMetadataBatch(saved.Dramas, sourceHongguo, nil, time.Now()); len(batch) != 0 {
t.Fatal("restart lost completed checks or failure cooldown")
}
for index, row := range saved.Dramas {
if row.ID == "hongguo:"+bad {
saved.Dramas[index].SortMetadata = &sortMetadataState{CheckedAt: time.Now().Add(-sortMetadataRetryDelay - time.Second)}
}
}
fail.Store(false)
previous := calls.Load()
patches, failures := d.backfillSortMetadata(withKnownHongguoDramas(context.Background(), saved.Dramas), sourceHongguo)
if len(failures) != 0 || len(patches) != 1 || patches[0].ID != "hongguo:"+bad || calls.Load()-previous != 1 {
t.Fatal("retry did not resume only the failed entry")
}
}
func TestManualMetadataPriorityValidation(t *testing.T) {
a := &UIApp{}
for _, path := range []string{
"/api/ui/dramas?more=1&source=invalid",
"/api/ui/dramas?more=1&refresh=1",
"/api/ui/dramas?update=1&refresh=1",
"/api/ui/dramas?update=1&more=1",
"/api/ui/dramas?update=1&source=invalid",
"/api/ui/dramas?update=1&priority=" + strings.Repeat("hongguo:700001,", sortMetadataBatchSize),
"/api/ui/dramas?update=1&priority=hongguo:" + strings.Repeat("1", 121),
"/api/ui/dramas?more=1&priority=" + strings.Repeat("id,", sortMetadataBatchSize),
} {
writer := httptest.NewRecorder()
a.handleDramas(writer, httptest.NewRequest("GET", path, nil))
if writer.Code != 400 {
t.Fatalf("invalid request was not rejected: %d", writer.Code)
}
}
}