Files
ONE/backend/internal/thumbs/thumbs_test.go
T
Sakurasan 232abf2019 缩略图缓存加容量闸与删除联动清理
- Store 增 MaxBytes(默认 256MB):写入后节流扫描(10 分钟一次、同时只跑一个),
  超限按 mtime 从旧到新删到 3/4 水位(滞回,避免卡在边界反复删),顺带清掉
  进程被杀留下的 .tmp
- 命中缓存时 Touch 更新 mtime,淘汰因此是 LRU 而非「最早生成的先死」,
  热图不会被误删后反复回源拉存储
- 新增 Purge(sha):后台删除上传文件时连带清掉该图所有宽度的产物与 .failed
  标记,否则已删图片会永久占着缓存
2026-09-28 15:09:54 +08:00

173 lines
4.4 KiB
Go

package thumbs
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func makePNG(t *testing.T, w, h int, transparent bool) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// 左红右绿两半,方便验证盒均值没把通道混错
c := color.RGBA{255, 0, 0, 255}
if x >= w/2 {
c = color.RGBA{0, 255, 0, 255}
}
if transparent {
c = color.RGBA{0, 0, 0, 0}
}
img.Set(x, y, c)
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestGenerateDownscale(t *testing.T) {
src := makePNG(t, 800, 400, false)
out, mime, pass, err := Generate(src, "image/png", 400)
if err != nil {
t.Fatal(err)
}
if pass {
t.Fatal("800px 图在 w=400 不应直通")
}
if mime != "image/jpeg" {
t.Fatalf("mime=%s", mime)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
if cfg.Width != 400 || cfg.Height != 200 {
t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height)
}
img, err := jpeg.Decode(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
// 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量)
r, g, _, _ := img.At(100, 100).RGBA()
if r>>8 < 200 || g>>8 > 60 {
t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8)
}
r, g, _, _ = img.At(300, 100).RGBA()
if g>>8 < 200 || r>>8 > 60 {
t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8)
}
}
func TestGeneratePassThrough(t *testing.T) {
src := makePNG(t, 300, 150, false)
out, mime, pass, err := Generate(src, "image/png", 960)
if err != nil {
t.Fatal(err)
}
if !pass || mime != "image/png" || !bytes.Equal(out, src) {
t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime)
}
}
func TestGenerateRejectsGarbage(t *testing.T) {
if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil {
t.Fatal("垃圾字节应报错")
}
}
func TestFlattenAlphaUnpremultiply(t *testing.T) {
// 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.NRGBA{255, 0, 0, 128})
flat := flatten(img, img.Bounds())
if flat[0] < 240 || flat[3] != 128 {
t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3])
}
}
func TestSweepEvictsOldest(t *testing.T) {
s := NewStore(t.TempDir())
s.MaxBytes = 2500 // 每个文件 1000B,留得下 2 个
sha := func(i int) string { return strings.Repeat(string(rune('a'+i)), 64) }
// 三个宽度:写入顺序即 mtime 顺序(下面再显式拨时间)
for i := 0; i < 3; i++ {
if _, err := s.Put(sha(i), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
// 跳过节流,让这次 Put 真的触发扫描
s.lastSweep = time.Time{}
oldest := time.Now().Add(-3 * time.Hour)
for i := 0; i < 2; i++ { // 前两个假装很久没人访问
os.Chtimes(s.jpegPath(sha(i), 480), oldest, oldest)
}
if _, err := s.Put(sha(3), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
t.Fatal(err)
}
var total int64
var kept []string
filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
fi, _ := d.Info()
total += fi.Size()
kept = append(kept, d.Name())
return nil
})
if total > s.MaxBytes {
t.Fatalf("sweep 后仍超限: total=%d max=%d files=%v", total, s.MaxBytes, kept)
}
if len(kept) >= 4 {
t.Fatalf("一个都没淘汰,说明扫描没跑: files=%v", kept)
}
// 最新写入的那个必须还在
if s.FindCached(sha(3), 480) == "" {
t.Fatalf("刚写入的缓存被误删: %v", kept)
}
}
func TestPurgeRemovesAllEntriesForFile(t *testing.T) {
s := NewStore(t.TempDir())
sha := strings.Repeat("f", 64)
for _, w := range []int{480, 960} {
if _, err := s.Put(sha, w, []byte("data"), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
s.MarkFailed(sha)
other := strings.Repeat("a", 64)
if _, err := s.Put(other, 480, []byte("keep"), "image/jpeg"); err != nil {
t.Fatal(err)
}
s.Purge(sha)
for _, w := range []int{480, 960} {
if p := s.FindCached(sha, w); p != "" {
t.Fatalf("purge 后仍残留 %s", p)
}
}
if s.FailedRecently(sha) {
t.Fatal("失败标记没清掉")
}
if s.FindCached(other, 480) == "" {
t.Fatal("误删了别的文件的缓存")
}
}