Ревью: preflight готовности источника для команд ревью (MAJOR-5)
Команды ревью проверяли только наличие раздачи в qBittorrent, но не её готовность. Недокачанную задачу можно припарковать в deferred, затем «Распознать заново» → recognizing → авто-раскладка (Rerecognize/Refine/ SetType не ставят force_review) → хардлинки на неполные файлы. Даже ручной Apply не имел preflight завершённости. Вводим ensureSourceReady (classify(t.State)==classReady) вместо ensureSourcePresent во всех командах, которым нужен источник (Relink/ Rerecognize/Refine/SetType), и inline-проверку класса в Apply — последний рубеж перед хардлинками. Недокачанный источник → отдельный sentinel ErrNotReady (409) с actionable-текстом «торрент ещё качается» в web и Telegram, без reconcile (состояние deferred/review легитимно). Change review-readiness-preflight заархивирован, дельта влита в openspec/specs/review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -107,7 +108,7 @@ func revertedDownload(id string) *store.Download {
|
||||
func TestRelink_RevertedToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload("1"))
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), "1"); err != nil {
|
||||
@@ -126,7 +127,7 @@ func TestRelink_CancelledToRecognizing(t *testing.T) {
|
||||
d := revertedDownload("1")
|
||||
d.State = store.StateCancelled
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), "1"); err != nil {
|
||||
@@ -156,7 +157,7 @@ func TestRerecognize_ReviewToRecognizing(t *testing.T) {
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), "1"); err != nil {
|
||||
@@ -219,6 +220,87 @@ func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewCommands_RejectNotReadySource — readiness-preflight: команды,
|
||||
// вводящие задачу в recognizing, отклоняют недокачанный (downloading-класс)
|
||||
// источник с ErrNotReady и НЕ трогают состояние. Иначе недокачанную задачу
|
||||
// можно было бы провести в авто-раскладку и захардлинкать неполные файлы.
|
||||
func TestReviewCommands_RejectNotReadySource(t *testing.T) {
|
||||
notReady := []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "downloading"}}
|
||||
|
||||
// review/deferred-команды на задаче в deferred/review.
|
||||
cases := []struct {
|
||||
name string
|
||||
state store.State
|
||||
call func(w *Worker) error
|
||||
}{
|
||||
{"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }},
|
||||
{"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }},
|
||||
{"set type", store.StateReview, func(w *Worker) error { return w.SetType(context.Background(), "1", "series") }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload("1")
|
||||
d.State = tc.state
|
||||
st.put(d)
|
||||
w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := tc.call(w); !errors.Is(err, ErrNotReady) {
|
||||
t.Fatalf("err = %v, want ErrNotReady", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != tc.state {
|
||||
t.Errorf("state = %q, want %q (не тронуто)", got, tc.state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Relink из reverted — тоже требует готовности источника.
|
||||
t.Run("relink", func(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload("1"))
|
||||
w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), "1"); !errors.Is(err, ErrNotReady) {
|
||||
t.Fatalf("err = %v, want ErrNotReady", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateReverted {
|
||||
t.Errorf("state = %q, want reverted (не тронуто)", got)
|
||||
}
|
||||
if _, ok := st.overrides["1"][ovrForceReview]; ok {
|
||||
t.Errorf("force_review проставлен, а не должен: отказ до записи override")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApply_RejectsNotReadySource — последний рубеж: Apply не создаёт хардлинки
|
||||
// на недокачанный источник, даже если задача оказалась в review.
|
||||
func TestApply_RejectsNotReadySource(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
planJSON, _ := json.Marshal(seriesResult().Plan)
|
||||
st.recs = append(st.recs, &store.Recognition{
|
||||
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||
})
|
||||
lay, err := layout.New(layout.Config{MoviesDir: t.TempDir(), SeriesDir: t.TempDir()}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: "/d", State: "downloading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
|
||||
|
||||
if err := w.Apply(context.Background(), "1"); !errors.Is(err, ErrNotReady) {
|
||||
t.Fatalf("err = %v, want ErrNotReady", err)
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateReview {
|
||||
t.Errorf("state = %q, want review (не тронуто)", got)
|
||||
}
|
||||
if len(st.links) != 0 {
|
||||
t.Errorf("file_links = %d, want 0 (хардлинки не создаём)", len(st.links))
|
||||
}
|
||||
}
|
||||
|
||||
// memStore — полноценный in-memory store для тестов Ф3.
|
||||
type memStore struct {
|
||||
downloads map[string]*store.Download
|
||||
@@ -691,7 +773,7 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Refine(context.Background(), "1", "это второй сезон"); err != nil {
|
||||
@@ -713,7 +795,7 @@ func TestSetType(t *testing.T) {
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.SetType(context.Background(), "1", "series"); err != nil {
|
||||
@@ -807,7 +889,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
||||
st.recs = append(st.recs, &store.Recognition{
|
||||
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||
})
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit", State: "uploading"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
|
||||
|
||||
return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series}
|
||||
|
||||
Reference in New Issue
Block a user