diff --git a/internal/datasource/biblioreads/adapter.go b/internal/datasource/biblioreads/adapter.go index 96274ad1..7315e3aa 100644 --- a/internal/datasource/biblioreads/adapter.go +++ b/internal/datasource/biblioreads/adapter.go @@ -14,7 +14,7 @@ const ( // maxConcurrentLookups bounds lookups across every enrichment caller, so they do not pile up // waiting on the client. Client request spacing and cooldowns protect each instance. maxConcurrentLookups = 4 - requestTimeout = 2 * time.Minute + requestTimeout = 3 * time.Minute ) type SearchSource interface { diff --git a/internal/datasource/biblioreads/adapter_test.go b/internal/datasource/biblioreads/adapter_test.go index f1758a68..ef7fb4a9 100644 --- a/internal/datasource/biblioreads/adapter_test.go +++ b/internal/datasource/biblioreads/adapter_test.go @@ -875,6 +875,36 @@ func TestTitleFallback(t *testing.T) { } } +func TestUnavailableSearchDoesNotBroadenQuery(t *testing.T) { + const title = "El economista camuflado" + const author = "Tim Harford" + source := &fakeSource{ + byQuery: map[string][]SearchResult{ + title: {{Title: title, Author: author, BookURL: "/book/show/123", Rating: "4.00"}}, + }, + queryErrors: map[string]error{title + " " + author: ErrUnavailable}, + } + idx := newTestIndexer(t, source) + doc := index.Document{ID: testID, Metadata: metadata.Metadata{Title: title, Authors: []string{author}}} + if err := index.IndexDocument(idx.documentsIdx, doc.ID, doc); err != nil { + t.Fatal(err) + } + got, err := idx.Book(context.Background(), doc) + if !errors.Is(err, ErrUnavailable) || got.BookURL != "" { + t.Fatalf("expected unavailable search without a match, got %+v, %v", got, err) + } + stored, err := idx.DocumentByID(doc.ID) + if err != nil { + t.Fatal(err) + } + if mustData(t, stored).Enriched { + t.Fatal("failed search must remain pending") + } + if !slices.Equal(source.Queries(), []string{title + " " + author}) { + t.Fatalf("failed search must not trigger a title-only query, got %q", source.Queries()) + } +} + func TestMatchesTolkienOmnibusSearchResults(t *testing.T) { const title = "El señor de los anillos" const wantURL = "/book/show/37910810-el-se-or-de-los-anillos" diff --git a/internal/datasource/biblioreads/client.go b/internal/datasource/biblioreads/client.go index 21c54b0c..db15e3dc 100644 --- a/internal/datasource/biblioreads/client.go +++ b/internal/datasource/biblioreads/client.go @@ -89,7 +89,8 @@ func WithCacheTTL(ttl time.Duration) Option { // instances ready to accept a request and randomizing ties. Requests to each // instance are spaced at least one second apart by default, including failover // attempts. Unavailable instances are skipped until their Retry-After time, or -// httpjson.DefaultRetryAfter, has passed. Successful responses are cached in memory for +// httpjson.DefaultRetryAfter, has passed. Book searches wait if all instances are cooling down. +// Successful responses are cached in memory for // DefaultCacheTTL unless changed with WithCacheTTL. No endpoints are configured // by default; callers must supply them with WithInstances. func NewClient(opts ...Option) *Client { @@ -131,7 +132,7 @@ type reviewsPayload struct { LanguageCode string `json:"languageCode,omitempty"` } -// SearchBooks searches Goodreads books matching query. +// SearchBooks searches Goodreads books matching query, with at most three attempts on retryable errors. func (c *Client) SearchBooks(ctx context.Context, query string) (SearchBooksResponse, error) { var res SearchBooksResponse payload := queryURLPayload{ @@ -212,8 +213,15 @@ func (c *Client) post(ctx context.Context, path string, payload, dest any) error var errs []error var incompleteResponse []byte tried := make(map[string]bool) - for len(tried) < len(c.instances) { - instance, err := c.acquireInstance(ctx, tried) + attempts := len(c.instances) + if path == searchBooksPath { + attempts = 3 + } + for range attempts { + if len(tried) == len(c.instances) { + clear(tried) + } + instance, err := c.acquireInstance(ctx, tried, path == searchBooksPath) if err != nil { if ctx.Err() != nil { return ctx.Err() @@ -310,7 +318,7 @@ func supportsReviewLanguage(version string) bool { // acquireInstance rechecks availability after waiting, so cancellation does not // reserve a future slot and concurrent requests cannot take the same slot. -func (c *Client) acquireInstance(ctx context.Context, tried map[string]bool) (string, error) { +func (c *Client) acquireInstance(ctx context.Context, tried map[string]bool, waitForCooldown bool) (string, error) { for { if err := ctx.Err(); err != nil { return "", err @@ -318,19 +326,27 @@ func (c *Client) acquireInstance(ctx context.Context, tried map[string]bool) (st c.mu.Lock() start := rand.IntN(len(c.instances)) instance := "" + var readyAt time.Time now := time.Now() for i := range c.instances { candidate := c.instances[(start+i)%len(c.instances)] - if !tried[candidate] && !now.Before(c.cooldownUntil[candidate]) && - (instance == "" || c.nextRequest[candidate].Before(c.nextRequest[instance])) { + if tried[candidate] || (!waitForCooldown && now.Before(c.cooldownUntil[candidate])) { + continue + } + ready := c.nextRequest[candidate] + if c.cooldownUntil[candidate].After(ready) { + ready = c.cooldownUntil[candidate] + } + if instance == "" || ready.Before(readyAt) { instance = candidate + readyAt = ready } } if instance == "" { c.mu.Unlock() return "", fmt.Errorf("%w: no untried instance is outside its cooldown", ErrUnavailable) } - wait := time.Until(c.nextRequest[instance]) + wait := time.Until(readyAt) if wait <= 0 { c.nextRequest[instance] = time.Now().Add(c.requestInterval) c.mu.Unlock() @@ -391,7 +407,14 @@ func (c *Client) postToInstance(ctx context.Context, endpoint string, body []byt } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("biblioreads: request to %s failed with status code %d: %s", endpoint, resp.StatusCode, resp.Body) + responseErr := fmt.Errorf("biblioreads: request to %s failed with status code %d: %s", endpoint, resp.StatusCode, resp.Body) + if resp.StatusCode == http.StatusNotFound && strings.HasSuffix(endpoint, searchBooksPath) { + return nil, &unavailableError{ + endpoint: endpoint, retryAfter: httpjson.ParseRetryAfter(resp.Header.Get("Retry-After")), + reason: responseErr, + } + } + return nil, responseErr } return resp.Body, nil } diff --git a/internal/datasource/biblioreads/client_test.go b/internal/datasource/biblioreads/client_test.go index 556afdf2..4cc975b1 100644 --- a/internal/datasource/biblioreads/client_test.go +++ b/internal/datasource/biblioreads/client_test.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "os" "reflect" + "strings" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/svera/coreander/v5/internal/datasource/biblioreads" @@ -393,6 +396,121 @@ func TestErrorStatus(t *testing.T) { } } +type searchTransport func(*http.Request) (*http.Response, error) + +func (transport searchTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestSearchRetries(t *testing.T) { + fixture := string(readFixture(t, "search-books.json")) + for _, tt := range []struct { + name string + status int + body string + unavailable bool + attempts int + recover bool + }{ + {"invalid query", http.StatusNotFound, `{"status":"Error - Invalid Query"}`, true, 3, false}, + {"legitimate not found", http.StatusNotFound, `{"status":"Error - Book not found"}`, true, 3, false}, + {"non-JSON not found", http.StatusNotFound, "Not found", true, 3, false}, + {"other client error", http.StatusBadRequest, `{"status":"Error - Invalid Query"}`, false, 1, false}, + {"server error", http.StatusServiceUnavailable, `{"status":"Error - Invalid Query"}`, true, 3, false}, + {"rate limit", http.StatusTooManyRequests, `{}`, true, 3, false}, + {"recovery", http.StatusNotFound, `{"status":"Error - Invalid Query"}`, false, 3, true}, + {"empty success", http.StatusOK, `{"status":"Received","result":[]}`, false, 1, false}, + } { + t.Run(tt.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + attempts := 0 + client := newTestClient(biblioreads.WithInstances([]string{"https://example.test"}), + biblioreads.WithHTTPClient(&http.Client{Transport: searchTransport(func(r *http.Request) (*http.Response, error) { + var captured capturedRequest + if !captureRequest(t, httptest.NewRecorder(), r, &captured) { + t.Fatal("invalid search request") + } + assertRequest(t, captured, "/api/search/books", map[string]any{ + "queryURL": "https://www.goodreads.com/search?q=El%20economista%20camuflado%20Tim%20Harford", + }) + attempts++ + status, body := tt.status, tt.body + if tt.recover && attempts == 3 { + status, body = http.StatusOK, fixture + } + header := make(http.Header) + if status == http.StatusTooManyRequests { + header.Set("Retry-After", "1") + } + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(body))}, nil + })})) + start := time.Now() + response, err := client.SearchBooks(context.Background(), "El economista camuflado Tim Harford") + wantError := !tt.recover && tt.status != http.StatusOK + if (err != nil) != wantError || errors.Is(err, biblioreads.ErrUnavailable) != tt.unavailable { + t.Fatalf("unexpected search error classification: %v", err) + } + if attempts != tt.attempts { + t.Fatalf("expected %d attempts, got %d", tt.attempts, attempts) + } + cooldown := time.Minute + if tt.status == http.StatusTooManyRequests { + cooldown = time.Second + } + if attempts == 3 && time.Since(start) != 2*cooldown { + t.Fatalf("retries must respect the instance cooldown: %s", time.Since(start)) + } + if tt.recover && len(response.Result) == 0 { + t.Fatal("successful retry must return its results") + } + }) + }) + } +} + +func TestSearchRetryWaitHonorsCancellation(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + attempts := 0 + client := newTestClient(biblioreads.WithInstances([]string{"https://example.test"}), + biblioreads.WithHTTPClient(&http.Client{Transport: searchTransport(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusNotFound, Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"status":"Error - Invalid Query"}`))}, nil + })})) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + start := time.Now() + _, err := client.SearchBooks(ctx, "book") + if !errors.Is(err, context.DeadlineExceeded) || attempts != 1 || time.Since(start) != 10*time.Second { + t.Fatalf("cancellation must interrupt cooldown: attempts=%d, elapsed=%s, err=%v", attempts, time.Since(start), err) + } + }) +} + +func TestSearchInvalidQueryFailsOverWithoutChangingQuery(t *testing.T) { + var hits atomic.Int32 + fixture := readFixture(t, "search-books.json") + client := newFailoverClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var captured capturedRequest + if !captureRequest(t, w, r, &captured) { + return + } + assertRequest(t, captured, "/api/search/books", map[string]any{ + "queryURL": "https://www.goodreads.com/search?q=El%20economista%20camuflado%20Tim%20Harford", + }) + if hits.Add(1) == 1 { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"status":"Error - Invalid Query","version":"BiblioReads v4.4.0"}`)) + return + } + _, _ = w.Write(fixture) + })) + response, err := client.SearchBooks(context.Background(), "El economista camuflado Tim Harford") + if err != nil || len(response.Result) == 0 || hits.Load() != 2 { + t.Fatalf("expected same-query recovery on another instance, got %+v, %v, requests=%d", response, err, hits.Load()) + } +} + func newCountingServer(t *testing.T, status int, body string, hits *atomic.Int32) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/index/bleve.go b/internal/index/bleve.go index 93e3d513..0841a673 100644 --- a/internal/index/bleve.go +++ b/internal/index/bleve.go @@ -244,11 +244,13 @@ type progressTracker struct { startNanos atomic.Int64 processed atomic.Uint64 total atomic.Uint64 + failed atomic.Bool } // begin marks the phase as started, resetting processed to 0 and total to // the given count. func (p *progressTracker) begin(total int) { + p.failed.Store(false) p.startNanos.Store(time.Now().UnixNano()) p.processed.Store(0) p.total.Store(uint64(total)) diff --git a/internal/index/bleve_document_enrich.go b/internal/index/bleve_document_enrich.go index df19a292..b048c44d 100644 --- a/internal/index/bleve_document_enrich.go +++ b/internal/index/bleve_document_enrich.go @@ -76,13 +76,16 @@ func (b *BleveIndexer) documentEnrichTracker(i int) *progressTracker { return &progressTracker{} } -func (b *BleveIndexer) enrichProvider(provider DocumentEnricher, tracker *progressTracker, batchSize int) error { +func (b *BleveIndexer) enrichProvider(provider DocumentEnricher, tracker *progressTracker, batchSize int) (resultErr error) { + defer func() { + tracker.failed.Store(resultErr != nil) + tracker.end() + }() _, total, err := b.documentsNeedingProvider(provider.Source(), 0, nil) if err != nil || total == 0 { return err } tracker.begin(int(total)) - defer tracker.end() log.Printf("Enriching %d documents with %s data", total, provider.Source()) var failures []error failedDocuments := make(map[string]struct{}) diff --git a/internal/index/bleve_document_enrich_test.go b/internal/index/bleve_document_enrich_test.go index 69fcc838..a8f242e6 100644 --- a/internal/index/bleve_document_enrich_test.go +++ b/internal/index/bleve_document_enrich_test.go @@ -49,6 +49,9 @@ func TestDocumentEnrichmentUsesBoundedConcurrency(t *testing.T) { } idx, _ := newTestTextRankIndexer(t, Config{}) documents := make([]Document, documentCount) + for i := range documents { + documents[i] = Document{ID: fmt.Sprintf("book-%d", i)} + } done := make(chan map[string]error, 1) go func() { done <- idx.enrichProviderBatch(provider, &progressTracker{}, documents) }() defer func() { @@ -126,8 +129,8 @@ func TestBackgroundProgressTracksPersistedProviderLookups(t *testing.T) { t.Fatal("expected unavailable provider to fail") } progress, err = idx.BackgroundProgress() - if err != nil || len(progress) != 1 || progress[0].Name != "first" || progress[0].Percentage != 66 { - t.Fatalf("failed lookups must remain visible; successful providers must disappear: %+v, %v", progress, err) + if err != nil || len(progress) != 0 { + t.Fatalf("finished providers must disappear, including failed lookups: %+v, %v", progress, err) } // Progress is reconstructed from stored data, not a running worker counter. resumed := &BleveIndexer{documentsIdx: idx.documentsIdx, documentEnrichers: []DocumentEnricher{first}} @@ -228,6 +231,18 @@ func TestDocumentEnrichmentContinuesAfterDocumentFailure(t *testing.T) { if len(pending) != 1 || pending[0].ID != "book-2" { t.Fatalf("failed document should remain pending for a later retry: %+v", pending) } + progress, err := idx.BackgroundProgress() + if err != nil || len(progress) != 0 { + t.Fatalf("failed run must not report active progress: %+v, %v", progress, err) + } + provider.failure = nil + if err := idx.EnrichDocuments(1); err != nil { + t.Fatal(err) + } + progress, err = idx.BackgroundProgress() + if err != nil || len(progress) != 0 || idx.documentEnrichProgress[0].failed.Load() { + t.Fatalf("successful retry must clear the failed state: %+v, %v", progress, err) + } } func TestDocumentEnrichmentSupportsIndependentProviders(t *testing.T) { diff --git a/internal/index/bleve_document_write.go b/internal/index/bleve_document_write.go index 8e29a4b1..b3d12c63 100644 --- a/internal/index/bleve_document_write.go +++ b/internal/index/bleve_document_write.go @@ -81,11 +81,15 @@ func (b *BleveIndexer) BackgroundProgress() ([]Progress, error) { } for i, counts := range providers { if counts.enriched < counts.total { + tracker := b.documentEnrichTracker(i) + if tracker.failed.Load() { + continue + } progress = append(progress, Progress{ Kind: ProgressDataSources, InProgress: true, Name: b.documentEnrichers[i].Name(), // Do not round pending work up to 100%. Percentage: math.Floor(100 * float64(counts.enriched) / float64(counts.total)), - RemainingTime: b.documentEnrichTracker(i).remainingTime(counts.total - counts.enriched), + RemainingTime: tracker.remainingTime(counts.total - counts.enriched), }) } } diff --git a/internal/webserver/document_biblioreads_test.go b/internal/webserver/document_biblioreads_test.go index 0993d67d..1adb77af 100644 --- a/internal/webserver/document_biblioreads_test.go +++ b/internal/webserver/document_biblioreads_test.go @@ -110,8 +110,26 @@ func TestReviewEnrichmentProgressBanner(t *testing.T) { reviews.Find(`[role="progressbar"]`).AttrOr("aria-valuenow", "") != "66" { t.Fatalf("incorrect persisted lookup progress: %s", reviews.Text()) } + bar := reviews.Find(".progress-bar") + if !bar.HasClass("bg-info-subtle") || !bar.HasClass("text-body-emphasis") || bar.HasClass("bg-info") { + t.Fatal("review progress must use a theme-aware background with contrasting text") + } } checkPending() + for lang, label := range map[string]string{ + "en": "Retrieving ratings", + "es": "Recuperando valoraciones", + "de": "Bewertungen werden abgerufen", + "fr": "Récupération des évaluations", + "ru": "Получение оценок", + } { + localized := getPage(t, app, "/background-progress?l="+lang) + reviews := localized.Find(`[data-progress-kind="data-sources"]`) + if reviews.Find(`[role="progressbar"]`).AttrOr("aria-label", "") != label || + !strings.Contains(reviews.Find("p").First().Text(), label) { + t.Errorf("%s: review progress must use the localized retrieval label %q", lang, label) + } + } spanish := getPage(t, app, "/background-progress?l=es") if !strings.Contains(spanish.Find("#background-progress").Text(), "La información de valoraciones está incompleta") { t.Fatal("live progress must retain the selected language") @@ -119,7 +137,15 @@ func TestReviewEnrichmentProgressBanner(t *testing.T) { if err := idx.EnrichDocuments(1); err == nil { t.Fatal("expected unavailable source to fail") } - checkPending() + for _, lang := range []string{"en", "es", "de", "fr", "ru"} { + localized := getPage(t, app, "/background-progress?l="+lang) + if localized.Find(`[data-progress-kind="data-sources"]`).Length() != 0 { + t.Errorf("%s: failed ratings retrieval must not display a message or progress bar", lang) + } + if localized.Find(`[data-progress-kind="documents"]`).Length() != 1 { + t.Errorf("%s: hiding failed ratings must preserve active indexing progress", lang) + } + } source.searchErr = nil if err := idx.EnrichDocuments(1); err != nil { t.Fatal(err) @@ -139,6 +165,21 @@ func TestReviewEnrichmentProgressBanner(t *testing.T) { } } +func TestFailedReviewEnrichmentLeavesNoAlert(t *testing.T) { + app, idx := newBiblioReadsTestApp(t, &fakeBookSource{searchErr: biblioreads.ErrUnavailable}) + if err := idx.EnrichDocuments(1); err == nil { + t.Fatal("expected unavailable source to fail") + } + page := getPage(t, app, "/background-progress?l=en") + banner := page.Find("#background-progress") + if banner.Find(".alert").Length() != 0 || strings.TrimSpace(banner.Text()) != "" { + t.Fatal("failed ratings retrieval must not leave an empty alert or end-user message") + } + if banner.AttrOr("hx-trigger", "") != "every 5s" { + t.Fatal("progress polling must remain available for future tasks") + } +} + func newDataSourceProvidersTestApp(t *testing.T, source *fakeBookSource, provider document.ReviewProvider, dataSourceLinkProvider authorcontroller.DataSourceLinkProvider) (*fiber.App, *index.BleveIndexer) { t.Helper() db := infrastructure.Connect(":memory:", 250) diff --git a/internal/webserver/embedded/js/search-filter-utils.js b/internal/webserver/embedded/js/search-filter-utils.js index cf94a027..2793c7d1 100644 --- a/internal/webserver/embedded/js/search-filter-utils.js +++ b/internal/webserver/embedded/js/search-filter-utils.js @@ -197,15 +197,20 @@ export function initDateControls(searchFilters, searchFiltersForm) { } export function initClearRangeControls(searchFilters) { + function updateClearButtons() { + searchFilters.querySelectorAll('.clear-range-control').forEach(clearLink => { + const group = clearLink.closest('.input-group') + if (group) updateRangeClearButtonState(group) + }) + } + + searchFilters.addEventListener('input', updateClearButtons) + searchFilters.addEventListener('change', updateClearButtons) + searchFilters.querySelectorAll('.clear-range-control').forEach(clearLink => { const group = clearLink.closest('.input-group') if (!group) return - group.querySelectorAll('input, select').forEach(input => { - input.addEventListener('input', () => updateRangeClearButtonState(group)) - input.addEventListener('change', () => updateRangeClearButtonState(group)) - }) - clearLink.addEventListener('click', (e) => { e.preventDefault() resetRangeGroup(group) diff --git a/internal/webserver/embedded/translations/de.yml b/internal/webserver/embedded/translations/de.yml index 5e32fd4b..fafb347e 100644 --- a/internal/webserver/embedded/translations/de.yml +++ b/internal/webserver/embedded/translations/de.yml @@ -426,6 +426,6 @@ "No score": "Keine Bewertung" "Not scored": "Nicht bewertet" "%s ratings": "%s Bewertungen" -"Review information progress": "Fortschritt der Bewertungsinformationen" +"Retrieving ratings": "Bewertungen werden abgerufen" "Review information is incomplete. Score filters may omit documents whose ratings have not been retrieved.": "Bewertungsinformationen sind unvollständig. Bewertungsfilter können Dokumente auslassen, deren Bewertungen noch nicht abgerufen wurden." "Unable to retrieve background progress.": "Der Fortschritt der Hintergrundaufgaben konnte nicht abgerufen werden." diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index bcfea15c..dbadb263 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -422,6 +422,6 @@ "No score": "Sin puntuación" "Not scored": "Sin puntuar" "%s ratings": "%s valoraciones" -"Review information progress": "Progreso de la información de valoraciones" +"Retrieving ratings": "Recuperando valoraciones" "Review information is incomplete. Score filters may omit documents whose ratings have not been retrieved.": "La información de valoraciones está incompleta. Los filtros de puntuación pueden omitir documentos cuyas valoraciones aún no se han obtenido." "Unable to retrieve background progress.": "No se pudo obtener el progreso de las tareas en segundo plano." diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 1346ad3f..21280b21 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -424,6 +424,6 @@ "No score": "Aucune note" "Not scored": "Non noté" "%s ratings": "%s notes" -"Review information progress": "Progression des informations d'évaluation" +"Retrieving ratings": "Récupération des évaluations" "Review information is incomplete. Score filters may omit documents whose ratings have not been retrieved.": "Les informations d'évaluation sont incomplètes. Les filtres de note peuvent omettre les documents dont les évaluations n'ont pas encore été récupérées." "Unable to retrieve background progress.": "Impossible de récupérer la progression des tâches en arrière-plan." diff --git a/internal/webserver/embedded/translations/ru.yml b/internal/webserver/embedded/translations/ru.yml index 7fd89c59..edf04d04 100644 --- a/internal/webserver/embedded/translations/ru.yml +++ b/internal/webserver/embedded/translations/ru.yml @@ -418,6 +418,6 @@ "No score": "Нет оценки" "Not scored": "Не оценено" "%s ratings": "Оценок: %s" -"Review information progress": "Ход получения информации об оценках" +"Retrieving ratings": "Получение оценок" "Review information is incomplete. Score filters may omit documents whose ratings have not been retrieved.": "Информация об оценках неполная. Фильтры по оценкам могут пропускать документы, оценки которых ещё не получены." "Unable to retrieve background progress.": "Не удалось получить состояние фоновых задач." diff --git a/internal/webserver/embedded/views/partials/background-progress.html b/internal/webserver/embedded/views/partials/background-progress.html index 45ee5747..6de5c90d 100644 --- a/internal/webserver/embedded/views/partials/background-progress.html +++ b/internal/webserver/embedded/views/partials/background-progress.html @@ -11,14 +11,17 @@ {{if eq .Kind "authors"}}{{$label = "Author information progress"}} {{else if eq .Kind "textrank"}}{{$label = "Related content analysis progress"}} {{else if eq .Kind "pruning"}}{{$label = "Keyword clean-up progress"}} - {{else if eq .Kind "data-sources"}}{{$label = "Review information progress"}} + {{else if eq .Kind "data-sources"}} + {{$label = "Retrieving ratings"}} {{end}}

{{t $.Lang $label}}{{if .Name}} ({{.Name}}){{end}}

{{$remaining := ""}}{{if gt .RemainingTime 0}}{{$remaining = t $.Lang "%s left" (remainingTime .RemainingTime)}}{{end}} + {{if .InProgress}}
-
{{.Percentage}}%{{if $remaining}} ({{$remaining}}){{end}}
+
{{.Percentage}}%{{if $remaining}} ({{$remaining}}){{end}}
+ {{end}} {{if eq .Kind "data-sources"}}

{{t $.Lang "Review information is incomplete. Score filters may omit documents whose ratings have not been retrieved."}}

{{else if eq .Kind "authors"}} diff --git a/internal/webserver/js-tests/search-filter-utils.test.mjs b/internal/webserver/js-tests/search-filter-utils.test.mjs index 85063ed5..67d7ec32 100644 --- a/internal/webserver/js-tests/search-filter-utils.test.mjs +++ b/internal/webserver/js-tests/search-filter-utils.test.mjs @@ -20,9 +20,13 @@ for (const tag of ['input', 'select']) { : selector.split(', ').includes(tag) ? controls[0] : null, } button.closest = () => group - const filters = { - querySelectorAll: selector => selector === '.clear-range-control' ? [button] : [], - } + const filters = new EventTarget() + filters.querySelectorAll = selector => selector === '.clear-range-control' ? [button] : [] + controls.forEach(control => { + for (const type of ['input', 'change']) { + control.addEventListener(type, () => filters.dispatchEvent(new Event(type))) + } + }) const reset = initClearRangeControls(filters) assert.equal(button.disabled, true) @@ -54,3 +58,26 @@ for (const tag of ['input', 'select']) { assert.equal(button.disabled, true) }) } + +test('score range clear button refreshes after form values are synchronized', () => { + const controls = [ + { value: '', tagName: 'SELECT' }, + { value: '', tagName: 'SELECT' }, + ] + const button = new EventTarget() + const group = { + querySelectorAll: () => controls, + querySelector: () => button, + } + button.closest = () => group + const filters = new EventTarget() + filters.querySelectorAll = () => [button] + initClearRangeControls(filters) + assert.equal(button.disabled, true) + + for (const value of ['0', '3', '5', '']) { + controls[1].value = value + filters.dispatchEvent(new Event('input')) + assert.equal(button.disabled, value === '') + } +})