package main import ( "bytes" "encoding/base64" "image" "image/color" "image/jpeg" "image/png" "os" "path/filepath" "strings" "testing" ) func writeTestImage(t *testing.T, name string, w, h int, asJPEG bool) string { t.Helper() img := image.NewRGBA(image.Rect(0, 0, w, h)) for x := 0; x < w; x += 3 { for y := 0; y < h; y += 3 { img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) } } var buf bytes.Buffer var e error if asJPEG { e = jpeg.Encode(&buf, img, nil) } else { e = png.Encode(&buf, img) } if e != nil { t.Fatal(e) } p := filepath.Join(t.TempDir(), name) if e := os.WriteFile(p, buf.Bytes(), 0o644); e != nil { t.Fatal(e) } return p } func decodeDataURL(t *testing.T, dataURL string) image.Image { t.Helper() i := strings.Index(dataURL, ";base64,") if !strings.HasPrefix(dataURL, "data:image/") || i < 0 { t.Fatalf("not a data URL: %.40s", dataURL) } raw, e := base64.StdEncoding.DecodeString(dataURL[i+8:]) if e != nil { t.Fatal(e) } img, _, e := image.Decode(bytes.NewReader(raw)) if e != nil { t.Fatal(e) } return img } func TestImageFileToDataURLDownscalesLargeImages(t *testing.T) { p := writeTestImage(t, "big.png", 600, 400, false) dataURL, e := imageFileToDataURL(p) if e != nil { t.Fatal(e) } if !strings.HasPrefix(dataURL, "data:image/png;base64,") { t.Fatalf("png source should stay png: %.40s", dataURL) } img := decodeDataURL(t, dataURL) if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 170 { t.Fatalf("expected 256x170, got %dx%d", b.Dx(), b.Dy()) } } func TestImageFileToDataURLKeepsSmallJPEG(t *testing.T) { p := writeTestImage(t, "small.jpg", 120, 80, true) dataURL, e := imageFileToDataURL(p) if e != nil { t.Fatal(e) } if !strings.HasPrefix(dataURL, "data:image/jpeg;base64,") { t.Fatalf("jpeg source should stay jpeg: %.40s", dataURL) } img := decodeDataURL(t, dataURL) if b := img.Bounds(); b.Dx() != 120 || b.Dy() != 80 { t.Fatalf("small image should keep size, got %dx%d", b.Dx(), b.Dy()) } } func TestImageFileToDataURLErrors(t *testing.T) { if _, e := imageFileToDataURL(filepath.Join(t.TempDir(), "missing.png")); e == nil || e.Error() != "FILE_NOT_FOUND" { t.Fatalf("want FILE_NOT_FOUND, got %v", e) } bad := filepath.Join(t.TempDir(), "not-image.png") if e := os.WriteFile(bad, []byte("hello world"), 0o644); e != nil { t.Fatal(e) } if _, e := imageFileToDataURL(bad); e == nil || e.Error() != "AVATAR_DECODE_FAILED" { t.Fatalf("want AVATAR_DECODE_FAILED, got %v", e) } } func TestEnsureDefaultAvatar(t *testing.T) { s, e := OpenStore(filepath.Join(t.TempDir(), "defavatar.db")) if e != nil { t.Fatal(e) } defer s.db.Close() a := &App{store: s} a.ensureDefaultAvatar() st, e := s.Settings() if e != nil { t.Fatal(e) } if st.AvatarMode != "base64" || !strings.HasPrefix(st.AvatarValue, "data:image/") { t.Fatalf("default avatar not set: mode=%q value=%.40s", st.AvatarMode, st.AvatarValue) } if s.Meta("avatar_updated_at") == "" { t.Fatal("default avatar should bump avatar_updated_at so sync pushes it") } if img := decodeDataURL(t, st.AvatarValue); img.Bounds().Dx() > avatarMaxEdge || img.Bounds().Dy() > avatarMaxEdge { t.Fatalf("default avatar should fit %dpx, got %v", avatarMaxEdge, img.Bounds()) } // 已设置过头像时不覆盖。 st.AvatarMode, st.AvatarValue = "url", "https://cdn.example.com/me.png" if e = s.SaveSettings(st); e != nil { t.Fatal(e) } a.ensureDefaultAvatar() got, e := s.Settings() if e != nil { t.Fatal(e) } if got.AvatarMode != "url" || got.AvatarValue != "https://cdn.example.com/me.png" { t.Fatalf("existing avatar should not be overwritten: mode=%q", got.AvatarMode) } } func TestSaveSettingsBumpsAvatarTimestampOnlyOnAvatarChange(t *testing.T) { s, e := OpenStore(filepath.Join(t.TempDir(), "avatar.db")) if e != nil { t.Fatal(e) } defer s.db.Close() base, e := s.Settings() if e != nil { t.Fatal(e) } base.Theme = "light" if e = s.SaveSettings(base); e != nil { t.Fatal(e) } if got := s.Meta("avatar_updated_at"); got != "" { t.Fatalf("non-avatar save should not bump avatar timestamp, got %q", got) } base.AvatarMode, base.AvatarValue = "url", "https://cdn.example.com/a.png" if e = s.SaveSettings(base); e != nil { t.Fatal(e) } first := s.Meta("avatar_updated_at") if first == "" { t.Fatal("avatar change should bump timestamp") } got, e := s.Settings() if e != nil { t.Fatal(e) } if got.AvatarMode != "url" || got.AvatarValue != "https://cdn.example.com/a.png" { t.Fatalf("avatar not persisted: %+v", got) } // 非法模式会被清空存储。 base.AvatarMode, base.AvatarValue = "weird", "x" if e = s.SaveSettings(base); e != nil { t.Fatal(e) } got, _ = s.Settings() if got.AvatarMode != "" || got.AvatarValue != "" { t.Fatalf("invalid mode should clear avatar, got %+v", got) } }