layout: непомещающееся целевое имя уводит задачу в review вместо failed

- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
This commit is contained in:
av
2026-08-10 12:16:44 +03:00
parent 1710e5a9d5
commit b9f0929d0c
31 changed files with 1511 additions and 17 deletions
+139
View File
@@ -2406,3 +2406,142 @@ func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
}
}
// Раскладка с непомещающимся именем уводит задачу в review со своим кодом
// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст:
// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4).
func TestApply_NameTooLongGoesReview(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку
f := newApplyFixture(t, plan)
err := f.w.Apply(context.Background(), "1")
if !errors.Is(err, layout.ErrNameTooLong) {
t.Fatalf("Apply err = %v, want ErrNameTooLong", err)
}
d := f.st.downloads["1"]
if d.State != store.StateReview {
t.Errorf("state = %q, want review (а не failed)", d.State)
}
if d.ErrorCode.String != reasonNameTooLong {
t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong)
}
// A3: причина — наш доменный текст, а не текст ядра.
if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") ||
strings.Contains(msg, "ENAMETOOLONG") {
t.Errorf("error_msg несёт текст системной ошибки: %q", msg)
}
if !strings.Contains(d.ErrorMsg.String, "не помещается") {
t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String)
}
// A2/A4: под библиотекой не появилось ничего.
entries, rerr := os.ReadDir(f.series)
if rerr != nil {
t.Fatal(rerr)
}
if len(entries) != 0 {
t.Errorf("под series появилось %d записей, ожидалось 0", len(entries))
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links))
}
}
// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не
// поглотило соседний случай.
func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) {
plan := seriesResult().Plan
plan.Title = "..." // санитизация схлопывает в пустое
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("want build error")
}
if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild {
t.Errorf("error_code = %q, want %q", got, reasonBuild)
}
}
// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый
// частый вход в review — распознавание без подтверждённого матча — приходит
// туда с пустым error_msg и до раскладки не доходит, поэтому единственный
// источник причины здесь — построение предпросмотра.
func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200)
f := newApplyFixture(t, plan)
// Состояние без записанной причины: так задачу оставляет finishRecognition.
d := f.st.downloads["1"]
d.ErrorCode = store.NullString("")
d.ErrorMsg = store.NullString("")
f.st.put(d)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Preview) != 0 {
t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview))
}
if rd.PreviewError == "" {
t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка")
}
if !strings.Contains(rd.PreviewError, "не помещается") {
t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError)
}
}
// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из
// распознавания»: короткое название доводит задачу до done.
func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) {
plan := seriesResult().Plan
plan.Title = strings.Repeat("ы", 200)
f := newApplyFixture(t, plan)
if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) {
t.Fatalf("подготовка: ожидался отказ по длине, got %v", err)
}
// Человек задаёт название короче (жёсткая правка поля — override).
if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil {
t.Fatal(err)
}
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply после укорачивания: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done", got)
}
}
// Унаследованная база отказа по длине дать не может, и это свойство арифметики,
// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост
// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки
// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если
// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет
// раньше, чем задача упрётся в тупик, из которого подсказка не выводит.
func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) {
plan := seriesResult().Plan
f := newApplyFixture(t, plan)
prov, pid := "tvdb", "269613"
tag := prov + "id-" + pid
// Папка якоря ровно в предел: длиннее её на диске быть не может.
base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]"))
anchorDir := filepath.Join(f.series, base+" ["+tag+"]")
if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil {
t.Fatal(err)
}
f.st.links = append(f.st.links, store.FileLink{
DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"),
Status: string(layout.StatusLinked),
})
_ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov)
_ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("унаследованная база в предел должна раскладываться: %v", err)
}
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done", got)
}
}