first commit

This commit is contained in:
2026-10-11 12:56:38 +08:00
commit 7d21f317a4
41 changed files with 10758 additions and 0 deletions
+746
View File
@@ -0,0 +1,746 @@
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)
}
}