Files
hd4k-downloader/internal/core/core_test.go
T
2026-10-11 12:56:38 +08:00

747 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"hd4k-downloader/internal/hd4k"
)
// ---------------------------------------------------------------- 测试辅助
func newTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
s := NewStore(filepath.Join(dir, "state.json"))
t.Cleanup(func() {
s.Stop()
s.Flush()
})
return s
}
func sampleGallery() *hd4k.Gallery {
return &hd4k.Gallery{
ID: "migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html",
URL: "https://migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html",
Host: "migmg.ssuu.uk",
Path: "/content/09/myua-sweetbox-vol52.html",
Slug: "myua-sweetbox-vol52.html",
Title: "Myu_a SWEETBOX Vol.52 [190MB-53photos]",
Total: 3,
Images: []hd4k.ImageInfo{
{Index: 1, URL: "https://i0.wp.com/pic.4khd.com/a/1.webp?w=1300", Filename: "tbox-001.webp"},
{Index: 2, URL: "https://i0.wp.com/pic.4khd.com/a/2.webp?w=1300", Filename: "tbox-002.webp"},
{Index: 3, URL: "https://i0.wp.com/pic.4khd.com/a/3.webp?w=1300", Filename: "tbox-003.webp"},
},
Meta: hd4k.GalleryMeta{Category: "09", Slug: "myua-sweetbox-vol52.html", Host: "migmg.ssuu.uk"},
}
}
// ---------------------------------------------------------------- 目录模板
func TestBuildDirTemplates(t *testing.T) {
g := sampleGallery()
cases := []struct{ tpl, want string }{
{"{title}", "Myu_a SWEETBOX Vol.52 [190MB-53photos]"},
{"{name}", "myua-sweetbox-vol52"},
{"{slug}", "myua-sweetbox-vol52.html"},
{"{host}/{title}", "migmg.ssuu.uk/Myu_a SWEETBOX Vol.52 [190MB-53photos]"},
{"{category}/{name}", "09/myua-sweetbox-vol52"},
{"{unknown}", "{unknown}"}, // 未知占位符原样保留
}
for _, c := range cases {
got := BuildDir(c.tpl, g)
if got != filepath.FromSlash(c.want) {
t.Errorf("BuildDir(%q) = %q, 想要 %q", c.tpl, got, filepath.FromSlash(c.want))
}
}
}
// 占位符替换出来的值里的斜杠,绝不能变成真的目录层级。
// 站点标题里可能带 / : * ? " < > | 这些字符,直接落盘就是非法文件名。
func TestBuildDirNoInjectedHierarchy(t *testing.T) {
g := sampleGallery()
g.Title = `Bad/Title: With*Weird|Chars?And"Quotes"<Here>`
got := BuildDir("{title}", g)
segs := strings.Split(got, string(filepath.Separator))
if len(segs) != 1 {
t.Fatalf("标题里的非法字符造出了 %d 层目录: %q", len(segs), got)
}
if !validSegmentName(segs[0]) {
t.Errorf("段名仍不合法: %q", segs[0])
}
}
// 模板里**写出来的**分隔符才产生分层
func TestBuildDirTemplateSeparators(t *testing.T) {
g := sampleGallery()
got := BuildDir("{category}/{name}/{title}", g)
if len(strings.Split(got, string(filepath.Separator))) != 3 {
t.Errorf("模板里两个斜杠应产生 3 层: %q", got)
}
}
func TestBuildDirWindowsReservedName(t *testing.T) {
g := sampleGallery()
g.Title = "CON"
got := BuildDir("{title}", g)
if strings.EqualFold(got, "CON") {
t.Errorf("Windows 保留名没被转义: %q", got)
}
}
func TestBuildDirEmptyTemplateDefaultsToTitle(t *testing.T) {
g := sampleGallery()
if got := BuildDir("", g); got != SanitizeSegment(g.Title, g.Slug) {
t.Errorf("空模板应回落到标题: %q", got)
}
}
func TestListPlaceholders(t *testing.T) {
ph := ListPlaceholders()
want := map[string]bool{
"{title}": false, "{name}": false, "{slug}": false,
"{host}": false, "{category}": false, "{id}": false,
}
for _, p := range ph {
if _, ok := want[p]; ok {
want[p] = true
}
}
for p, seen := range want {
if !seen {
t.Errorf("占位符列表缺了 %s", p)
}
}
}
// ---------------------------------------------------------------- Store
func TestStoreAddTasksDedup(t *testing.T) {
s := newTestStore(t)
s.UpdateSettings(map[string]interface{}{"downloadRoot": t.TempDir()})
ref := hd4k.GalleryRef{
ID: "migmg.ssuu.uk/content/09/x.html",
URL: "https://migmg.ssuu.uk/content/09/x.html",
Host: "migmg.ssuu.uk",
Path: "/content/09/x.html",
Slug: "x.html",
}
if n := s.AddTasks([]hd4k.GalleryRef{ref, ref}); n != 1 {
t.Errorf("重复添加应只算 1 个, 实际 %d", n)
}
if got := len(s.Tasks()); got != 1 {
t.Errorf("任务数 = %d, 想要 1", got)
}
}
// 回归测试:UpsertTask / FindTask 必须深拷贝 Pages。
// 浅拷贝的话,图片级 worker 继续写同一个 map,而后台 Flush 正在
// json.Marshal 整个 state → fatal error: concurrent map write(进程闪退)。
func TestStoreDoesNotAliasPageMap(t *testing.T) {
s := newTestStore(t)
stored := s.UpsertTask(Task{
ID: "h/content/09/x.html",
Title: "t",
Pages: map[string]string{"1": "done"},
})
// 拿返回的副本改 Pages,不应影响内部
stored.Pages["1"] = "TAMPERED"
if got := s.FindTask("h/content/09/x.html").Pages["1"]; got != "done" {
t.Errorf("UpsertTask 返回值与内部共享了 Pages map: %q", got)
}
// FindTask 返回的也不应共享
found := s.FindTask("h/content/09/x.html")
found.Pages["2"] = "NEW"
if _, leak := s.FindTask("h/content/09/x.html").Pages["2"]; leak {
t.Error("FindTask 返回的 map 与内部共享了")
}
}
// 回归测试:UpdateSettings 曾经在持写锁时调 Settings() 取返回值,
// 而 Go 的 RWMutex 不可重入 → 永久死锁。
// 症状是「点保存设置后整个程序卡死」,且不报任何错,只有这里能抓到。
func TestUpdateSettingsDoesNotDeadlock(t *testing.T) {
s := newTestStore(t)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 50; i++ {
s.UpdateSettings(map[string]interface{}{"timeout": 1000 + i})
}
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("UpdateSettings 死锁了(RWMutex 不可重入)")
}
}
// 返回值不能和内部共享 Sites 切片 —— 调用方改它就绕过了锁
func TestUpdateSettingsDoesNotLeakSiteSlice(t *testing.T) {
s := newTestStore(t)
out := s.UpdateSettings(map[string]interface{}{})
out.Sites = append(out.Sites, Site{Host: "leaked.example", Enabled: true})
for _, site := range s.Settings().Sites {
if site.Host == "leaked.example" {
t.Fatal("UpdateSettings 的返回值泄漏了内部 Sites 切片")
}
}
}
func TestStoreClearKeepsFiles(t *testing.T) {
s := newTestStore(t)
s.AddTasks([]hd4k.GalleryRef{{ID: "a"}, {ID: "b"}})
s.UpsertTask(Task{ID: "a", Status: "done"})
s.UpsertTask(Task{ID: "b", Status: "queued"})
if n := s.ClearFinished(); n != 1 {
t.Errorf("清完成应删 1 个, 实际 %d", n)
}
tasks := s.Tasks()
if len(tasks) != 1 || tasks[0].ID != "b" {
t.Errorf("清理后应只剩 b: %+v", tasks)
}
}
func TestStoreRequeueFailed(t *testing.T) {
s := newTestStore(t)
for id, st := range map[string]string{
"a": "failed", "b": "partial", "c": "done", "d": "queued", "e": "running",
} {
s.UpsertTask(Task{ID: id, Status: st})
}
n := s.RequeueFailed()
// failed + partial + running = 3
if n != 3 {
t.Errorf("重排队数 = %d, 想要 3", n)
}
if s.FindTask("c").Status != "done" {
t.Error("已完成的任务不该被重排队")
}
}
func TestStoreRecoverOnStart(t *testing.T) {
s := newTestStore(t)
s.UpsertTask(Task{ID: "a", Status: "downloading"})
s.UpsertTask(Task{ID: "b", Status: "parsing"})
s.UpsertTask(Task{ID: "c", Status: "running"})
if n := s.RecoverOnStart(); n != 3 {
t.Errorf("归位数 = %d, 想要 3", n)
}
for _, id := range []string{"a", "b", "c"} {
if got := s.FindTask(id).Status; got != "queued" {
t.Errorf("%s 状态 = %q, 想要 queued", id, got)
}
}
}
func TestStoreSelectAll(t *testing.T) {
s := newTestStore(t)
s.AddTasks([]hd4k.GalleryRef{{ID: "a"}, {ID: "b"}, {ID: "c"}})
out := s.SelectAll([]string{"a", "c"})
sel := map[string]bool{}
for _, t := range out {
sel[t.ID] = t.Selected
}
if !sel["a"] || sel["b"] || !sel["c"] {
t.Errorf("勾选状态不对: %v", sel)
}
}
func TestStoreSettingsMergeAndSites(t *testing.T) {
s := newTestStore(t)
s.UpdateSettings(map[string]interface{}{"timeout": 12345})
if got := s.Settings().Timeout; got != 12345 {
t.Errorf("Timeout = %d, 想要 12345", got)
}
// 站点列表是结构体,走 map 补丁容易被反序列化坏 —— 必须能正确写入
s.UpdateSettings(map[string]interface{}{
"sites": []hd4k.Site{{Host: "example.com", Label: "EX", Enabled: true}},
})
sites := s.Settings().Sites
if len(sites) != 1 || sites[0].Host != "example.com" {
t.Fatalf("站点列表写入失败: %#v", sites)
}
// 改别的不该把站点清掉
s.UpdateSettings(map[string]interface{}{"timeout": 999})
if len(s.Settings().Sites) != 1 {
t.Errorf("改其它设置时站点列表被清空了: %#v", s.Settings().Sites)
}
}
func TestStoreFlushReload(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "state.json")
s := NewStore(p)
s.UpdateSettings(map[string]interface{}{"timeout": 4321})
s.AddTasks([]hd4k.GalleryRef{{ID: "x", Slug: "x.html", Title: "标题"}})
s.Stop()
s.Flush()
s2 := NewStore(p)
if got := s2.Settings().Timeout; got != 4321 {
t.Errorf("重载后 Timeout = %d, 想要 4321", got)
}
if got := s2.FindTask("x"); got == nil || got.Title != "标题" {
t.Errorf("重载后任务丢失: %#v", got)
}
}
// ---------------------------------------------------------------- 清单
func TestManifestRoundTrip(t *testing.T) {
dir := t.TempDir()
g := sampleGallery()
pageMap := map[string]ManifestPage{}
for _, im := range g.Images {
pageMap[fmt.Sprint(im.Index)] = ManifestPage{
Page: im.Index, File: im.Filename, URL: im.URL, Finished: true,
}
}
meta := g.Meta
in := Manifest{
ID: g.ID, URL: g.URL, Host: g.Host, Path: g.Path, Slug: g.Slug,
Title: g.Title, TotalPages: 3, Total: 3,
DonePages: []int{3, 1, 2},
PageMap: pageMap, Meta: &meta,
}
if err := WriteManifest(dir, in); err != nil {
t.Fatal(err)
}
out, err := ReadManifest(dir)
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("清单读不出来")
}
if out.Title != g.Title || out.Total != 3 {
t.Errorf("清单内容不对: %#v", out)
}
if !sortedInts(out.DonePages) {
t.Errorf("DonePages 未排序: %v", out.DonePages)
}
// 关键能力:清单里存的是稳定直链,所以能 0 请求重建画廊
back, ok := out.GalleryFromManifest()
if !ok {
t.Fatal("应能从清单重建画廊")
}
if len(back.Images) != 3 {
t.Fatalf("重建后图片数 = %d, 想要 3", len(back.Images))
}
for i, im := range back.Images {
if im.Index != i+1 {
t.Errorf("第 %d 张序号 = %d", i, im.Index)
}
if im.URL == "" {
t.Error("重建的图片丢了直链")
}
if !strings.Contains(im.URL, "w=1300") {
t.Errorf("直链丢了 w=1300(会 429): %s", im.URL)
}
}
}
func TestManifestIncompleteFallsBack(t *testing.T) {
dir := t.TempDir()
// 有 PageMap 但缺 URL —— 不能用,宁可重新解析
m := Manifest{
ID: "x",
Version: 2,
PageMap: map[string]ManifestPage{"1": {Page: 1, File: "a.webp"}},
}
if err := WriteManifest(dir, m); err != nil {
t.Fatal(err)
}
out, _ := ReadManifest(dir)
if _, ok := out.GalleryFromManifest(); ok {
t.Error("信息不全的清单不该被当成可用")
}
}
func TestReadManifestMissingIsNil(t *testing.T) {
m, err := ReadManifest(t.TempDir())
if err != nil {
t.Errorf("文件不存在不该报错: %v", err)
}
if m != nil {
t.Error("文件不存在应返回 nil")
}
}
func TestFindManifestByID(t *testing.T) {
root := t.TempDir()
id := "migmg.ssuu.uk/content/09/x.html"
if err := WriteManifest(filepath.Join(root, "x"), Manifest{ID: id}); err != nil {
t.Fatal(err)
}
if got := FindManifestByID(root, id); got == nil {
t.Fatal("按 ID 找不到清单")
}
if FindManifestByID(root, "nope") != nil {
t.Error("不该匹配到不存在的 ID")
}
}
func sortedInts(s []int) bool {
for i := 1; i < len(s); i++ {
if s[i] < s[i-1] {
return false
}
}
return true
}
// ---------------------------------------------------------------- 桥口
func newTestBridge(t *testing.T) *Bridge {
t.Helper()
s := newTestStore(t)
s.UpdateSettings(map[string]interface{}{
"bridgePort": 0,
"downloadRoot": t.TempDir(),
})
b := NewBridge(s, func(level, msg string) {}, func(t Task, added bool, batch bool) {})
t.Cleanup(b.Stop)
return b
}
// 起一个真实的桥口(127.0.0.1 上的随机端口)
func startBridge(t *testing.T) (*Bridge, string, *Store) {
t.Helper()
s := newTestStore(t)
s.UpdateSettings(map[string]interface{}{
"downloadRoot": t.TempDir(),
})
b := NewBridge(s, func(level, msg string) {}, func(t Task, added bool, batch bool) {})
t.Cleanup(b.Stop)
port := 0
for p := 38000; p < 38100; p++ {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
if err == nil {
ln.Close()
port = p
break
}
}
if port == 0 {
t.Skip("找不到空闲端口,跳过桥口测试")
}
b.Start(port)
return b, fmt.Sprintf("http://127.0.0.1:%d", port), s
}
func TestBridgePingAndInfo(t *testing.T) {
_, base, _ := startBridge(t)
resp, err := http.Get(base + "/ping")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("/ping 状态 = %d", resp.StatusCode)
}
resp, err = http.Get(base + "/info")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var info map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatal(err)
}
if info["token"] != BridgeToken {
t.Errorf("/info token = %v, 想要 %v", info["token"], BridgeToken)
}
// 站点域名(含别名)返回给油猴,用于生成 @match
hosts, _ := info["hosts"].([]interface{})
if len(hosts) == 0 {
t.Error("/info 没返回站点域名")
}
}
func TestBridgeAddRequiresToken(t *testing.T) {
_, base, _ := startBridge(t)
req, _ := http.NewRequest("POST", base+"/add", strings.NewReader(`{"url":"https://migmg.ssuu.uk/content/09/x.html"}`))
req.Header.Set("Content-Type", "application/json")
// 故意不带 token
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("无 token 应返回 403, 实际 %d", resp.StatusCode)
}
}
// 推的是已 404 的 www.4khd.com —— 应归一到主域名 migmg.ssuu.uk。
// 实测 migmg.ssuu.uk 首页里 90 个 /content/ 链接全都指向 www.4khd.com,
// 不归一的话后端会去请求死域名。
func TestBridgeAddNormalizesAliasDomain(t *testing.T) {
_, base, s := startBridge(t)
postJSON(t, base+"/add", map[string]interface{}{
"url": "https://www.4khd.com/content/09/myua-sweetbox-vol52.html",
"title": "Myu_a SWEETBOX Vol.52",
})
task := s.FindTask("migmg.ssuu.uk/content/09/myua-sweetbox-vol52.html")
if task == nil {
t.Fatalf("没找到归一化后的任务,现有: %+v", s.Tasks())
}
if task.Title != "Myu_a SWEETBOX Vol.52" {
t.Errorf("标题没带过来: %q", task.Title)
}
}
func TestBridgeAddBatchDedupAndInvalid(t *testing.T) {
_, base, s := startBridge(t)
postJSON(t, base+"/add-batch", map[string]interface{}{
"items": []map[string]interface{}{
{"url": "https://migmg.ssuu.uk/content/09/a.html", "title": "A"},
{"url": "https://www.4khd.com/content/09/a.html", "title": "A dup"}, // 别名 → 同一个
{"url": "https://migmg.ssuu.uk/content/09/b.html", "title": "B"},
{"url": "not-a-url"}, // 无效
},
})
if got := len(s.Tasks()); got != 2 {
t.Errorf("任务数 = %d, 想要 2(别名应去重、无效应忽略): %+v", got, s.Tasks())
}
}
func TestBridgeAddRejectsNonGallery(t *testing.T) {
_, base, s := startBridge(t)
resp := postJSONRaw(t, base+"/add", map[string]interface{}{
"url": "https://migmg.ssuu.uk/pages/popular",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("非画廊链接应返回 400, 实际 %d", resp.StatusCode)
}
if len(s.Tasks()) != 0 {
t.Error("非画廊链接不该入库")
}
}
// CORS 预检:Origin 必须在站点白名单里
func TestBridgeCORSOnlyForKnownOrigins(t *testing.T) {
_, base, _ := startBridge(t)
req, _ := http.NewRequest("OPTIONS", base+"/add", nil)
req.Header.Set("Origin", "https://migmg.ssuu.uk")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://migmg.ssuu.uk" {
t.Errorf("已知站点的 Origin 应被放行, 实际 %q", got)
}
req2, _ := http.NewRequest("OPTIONS", base+"/add", nil)
req2.Header.Set("Origin", "https://evil.example")
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if got := resp2.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("陌生 Origin 不应被放行, 实际 %q", got)
}
}
// 桥口只绑回环地址,不能暴露到局域网
func TestBridgeBindsLoopbackOnly(t *testing.T) {
b, _, _ := startBridge(t)
if b.Port() <= 0 {
t.Fatal("端口没起来")
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return
}
client := &http.Client{Timeout: 2 * time.Second}
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
continue
}
target := fmt.Sprintf("http://%s:%d/ping", ipnet.IP, b.Port())
if resp, err := client.Get(target); err == nil {
resp.Body.Close()
t.Errorf("桥口暴露到了 %s,应只绑 127.0.0.1", ipnet.IP)
}
}
}
func postJSON(t *testing.T, url string, payload map[string]interface{}) {
t.Helper()
resp := postJSONRaw(t, url, payload)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Errorf("POST %s 状态 = %d, body=%s", url, resp.StatusCode, string(b))
}
}
func postJSONRaw(t *testing.T, url string, payload map[string]interface{}) *http.Response {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("POST", url, bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-HD4K-Token", BridgeToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// ---------------------------------------------------------------- 杂项
func TestBridgeTokenIsNotEmpty(t *testing.T) {
if BridgeToken == "" {
t.Fatal("桥口 token 不能为空")
}
if AppName != "4KHD Downloader" {
t.Errorf("AppName = %q", AppName)
}
}
// 默认桥口端口不该和参考项目(EH 的 37777)撞车 —— 两个工具可能同时开着
func TestDefaultBridgePortDiffersFromReferenceProject(t *testing.T) {
port := DefaultSettings().BridgePort
if port == 37777 {
t.Error("默认端口与 EH Downloader 相同,两个工具会互相抢占桥口")
}
if port < 1024 || port > 65535 {
t.Errorf("默认端口不合法: %d", port)
}
}
func TestManifestFileName(t *testing.T) {
if ManifestFileName != ".hd4k.json" {
t.Errorf("清单文件名 = %q, 想要 .hd4k.json", ManifestFileName)
}
// 必须是点开头 —— 扫描时要靠这个跳过隐藏目录
if !strings.HasPrefix(ManifestFileName, ".") {
t.Error("清单文件名应以点开头")
}
}
func TestVersionConstants(t *testing.T) {
if AppVersion == "" {
t.Error("AppVersion 不能为空")
}
}
// 清理小图:只删小的图片文件,清单和 .part 临时文件不能碰
func TestSweepSmallImages(t *testing.T) {
root := t.TempDir()
gallery := filepath.Join(root, "画廊A")
if err := os.MkdirAll(gallery, 0o755); err != nil {
t.Fatal(err)
}
// 正常图(大于门槛)—— 必须留着
big := filepath.Join(gallery, "ok.webp")
if err := os.WriteFile(big, make([]byte, 20*1024), 0o644); err != nil {
t.Fatal(err)
}
// 小图 —— 应被删
tiny := filepath.Join(gallery, "junk.webp")
if err := os.WriteFile(tiny, make([]byte, 3*1024), 0o644); err != nil {
t.Fatal(err)
}
// 清单 —— 绝不能删(要留着续传)
man := filepath.Join(gallery, ManifestFileName)
if err := os.WriteFile(man, []byte(`{"tiny":"json"}`), 0o644); err != nil {
t.Fatal(err)
}
// .part 未完成的下载 —— 不该当图片删(可能正好小于门槛但没下完)
part := filepath.Join(gallery, "incomplete.webp.part")
if err := os.WriteFile(part, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
// 非图片的小文件 —— 不该删
other := filepath.Join(gallery, "note.txt")
if err := os.WriteFile(other, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
removed, freed, err := SweepSmallImages(root, MinImageBytes)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Errorf("删了 %d 个文件,想要 1", removed)
}
if freed != 3*1024 {
t.Errorf("释放 %d 字节,想要 %d", freed, 3*1024)
}
for _, keep := range []string{big, man, part, other} {
if _, err := os.Stat(keep); err != nil {
t.Errorf("不该被删的文件没了: %s", keep)
}
}
if _, err := os.Stat(tiny); !os.IsNotExist(err) {
t.Error("小图没被删掉")
}
}
// 根目录不存在时不该报错炸掉
func TestSweepSmallImagesMissingRoot(t *testing.T) {
removed, _, err := SweepSmallImages(filepath.Join(t.TempDir(), "nope"), MinImageBytes)
if err != nil {
t.Errorf("根目录不存在不该报错: %v", err)
}
if removed != 0 {
t.Errorf("删了 %d 个,想要 0", removed)
}
}