先查了「缓存 key 漏项」到底有没有实际影响,结论是**当前没有**: - 用户域名只在注册时写入(register.go / CreateUser),没有改域名的入口, 所以两张卡的二维码内容不会在运行期变化 - ExternalURL / ShareBackground 来自配置,改了必须重启,而缓存是进程内的, 重启即清空 —— 不存在「配置变了缓存还在」的窗口 - 头像、背景上传走 R2 随机 key(picture/日期/随机 hex,内容不可变), 换图必然换 URL,key 随之变化,不会出现「同名不同图」 只有一条理论缺口:域名要是将来能被修改,单题卡 key 没算 user.Domain, 会出现最长一小时的「二维码指向旧域名」。补上这一项成本为零,先按事实算进去。 shareCardCacheKey 本来就含 Domain,两张卡现在一致。 顺带把两处「TTL 30 分钟」的注释改成 1 小时(代码一直是 time.Hour)。 新增 TestShareCardCacheKeysCoverDrawnFields:逐个改动画进图里的字段, 断言 key 必须随之变化;去掉 user.Domain 后立刻报 "user.Domain is drawn on the question card but not in questionShareCardCacheKey"。
466 lines
15 KiB
Go
466 lines
15 KiB
Go
package question
|
|
|
|
import (
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/golang/freetype"
|
|
"github.com/golang/freetype/truetype"
|
|
qrcode "github.com/skip2/go-qrcode"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/conf"
|
|
"github.com/tamakyi/TamaBox/internal/db"
|
|
)
|
|
|
|
// 分享卡测试需要一份真实字体;中文站点部署时 conf.App.FontPath 必然存在,
|
|
// 本机开发/CI 缺失时跳过,避免把「没有字体」当成排版错误。
|
|
func TestMain(m *testing.M) {
|
|
if _, err := os.Stat(conf.App.FontPath); err != nil {
|
|
for _, candidate := range []string{
|
|
"C:/Windows/Fonts/NotoSansSC-VF.ttf",
|
|
"C:/Windows/Fonts/msyh.ttc",
|
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
} {
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
conf.App.FontPath = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func requireFont(t *testing.T) *truetype.Font {
|
|
t.Helper()
|
|
ft, err := getFont()
|
|
if err != nil {
|
|
t.Skipf("no usable font (%v), skip share card layout tests", err)
|
|
}
|
|
return ft
|
|
}
|
|
|
|
// measureTextWidth 必须与 freetype 实际笔位推进一致,否则居中会重新出现偏移。
|
|
func TestMeasureTextWidthMatchesAdvance(t *testing.T) {
|
|
requireFont(t)
|
|
cases := []struct {
|
|
text string
|
|
size float64
|
|
}{
|
|
{"https://box.shiroko.one/_/tamakyi", FontSizeLink},
|
|
{"box.shiroko.one/_/tamakyi", 13},
|
|
{"ask.some-quite-long-domain-name.example.com", LinkMinSize},
|
|
{"Powered by 狼的提问箱", FontSizeFooter},
|
|
{"扫码向我匿名提问", FontSizeQRHint},
|
|
}
|
|
for _, c := range cases {
|
|
dst := image.NewRGBA(image.Rect(0, 0, 1400, 80))
|
|
fctx := freetype.NewContext()
|
|
fctx.SetFont(requireFont(t))
|
|
fctx.SetDPI(72)
|
|
fctx.SetFontSize(c.size)
|
|
fctx.SetSrc(image.NewUniform(color.Black))
|
|
fctx.SetDst(dst)
|
|
pen, err := fctx.DrawString(c.text, freetype.Pt(0, 60))
|
|
if err != nil {
|
|
t.Fatalf("draw %q: %v", c.text, err)
|
|
}
|
|
want := float64(pen.X) / 64.0
|
|
if got := measureTextWidth(c.text, c.size); got != want {
|
|
t.Errorf("%q @%.1f: measure=%.2f, advance=%.2f", c.text, c.size, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 任意长度的链接都必须收进卡片,且不得缩到不可读。
|
|
func TestLayoutLinkFitsCard(t *testing.T) {
|
|
requireFont(t)
|
|
urls := []string{
|
|
"box.shiroko.one/_/tamakyi",
|
|
"box-dev-api.337845818.best/_/tamakyi",
|
|
"ask.some-quite-long-domain-name.example.com/_/tamakyi",
|
|
"questions.tamabox-community.example.org/anon/box/_/a-rather-long-handle-name",
|
|
"averyveryveryverylongdomainnamethatkeepsgoingandonandonforever.example/_/x",
|
|
}
|
|
for _, u := range urls {
|
|
lines, size := layoutLink(u)
|
|
if size < LinkMinSize {
|
|
t.Errorf("%q: font size %.2f below floor %.2f", u, size, LinkMinSize)
|
|
}
|
|
if len(lines) > 2 {
|
|
t.Errorf("%q: got %d lines, want <=2", u, len(lines))
|
|
}
|
|
for _, line := range lines {
|
|
if w := measureTextWidth(line, size); w > float64(LinkMaxWidth) {
|
|
t.Errorf("%q: line %q is %.1fpx, card allows %dpx", u, line, w, LinkMaxWidth)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 居中必须落在二维码卡片中线上(按真实着墨边界判断,允许左右侧边距带来的 3px 内偏差)。
|
|
func TestDrawTextCenteredIsCentered(t *testing.T) {
|
|
requireFont(t)
|
|
cx := QRCardX + QRCardW/2
|
|
for _, text := range []string{
|
|
"box.shiroko.one/_/tamakyi",
|
|
"box-dev-api.337845818.best/_/tamakyi",
|
|
"Powered by 狼的提问箱",
|
|
} {
|
|
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
|
fctx := freetype.NewContext()
|
|
fctx.SetFont(requireFont(t))
|
|
fctx.SetDPI(72)
|
|
drawTextCentered(canvas, text, cx, 300, FontSizeLink, ColorDarkText, fctx)
|
|
|
|
minX, maxX := -1, -1
|
|
for x := 0; x < CardWidth; x++ {
|
|
for y := 250; y < 320; y++ {
|
|
if r, _, _, a := canvas.At(x, y).RGBA(); a > 40 && r < 0x8000 {
|
|
if minX < 0 {
|
|
minX = x
|
|
}
|
|
maxX = x
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if minX < 0 {
|
|
t.Fatalf("%q: nothing drawn", text)
|
|
}
|
|
if center := (minX + maxX) / 2; abs(center-cx) > 3 {
|
|
t.Errorf("%q: ink center %d, want %d (±3)", text, center, cx)
|
|
}
|
|
if maxX > QRCardX+QRCardW || minX < QRCardX {
|
|
t.Errorf("%q: ink [%d,%d] escapes card [%d,%d]", text, minX, maxX, QRCardX, QRCardX+QRCardW)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 内嵌头像必须画在中心,且符号本身不被裁切(四角是定位图案,应当是黑的)。
|
|
func TestGenerateQRWithLogo(t *testing.T) {
|
|
logo := image.NewRGBA(image.Rect(0, 0, 64, 40))
|
|
draw.Draw(logo, logo.Bounds(), &image.Uniform{color.RGBA{200, 40, 40, 255}}, image.Point{}, draw.Src)
|
|
|
|
img, err := generateQR("https://box.shiroko.one/_/tamakyi", logo, QRCodeSize)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cx, cy := img.Bounds().Dx()/2, img.Bounds().Dy()/2
|
|
r, _, _, _ := img.At(cx, cy).RGBA()
|
|
if r < 0x8000 || r > 0xf000 {
|
|
t.Errorf("center pixel should be the logo color, got %#x", r)
|
|
}
|
|
for _, p := range []image.Point{{0, 0}, {img.Bounds().Dx() - 1, 0}, {0, img.Bounds().Dy() - 1}} {
|
|
r, g, b, _ := img.At(p.X, p.Y).RGBA()
|
|
if r == 0xffff && g == 0xffff && b == 0xffff {
|
|
t.Errorf("corner %v is white: finder pattern missing, symbol likely cropped", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func abs(v int) int {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|
|
|
|
// 二维码必须按模块整数倍绘制:任何宽度落差的游程都意味着模块被拉伸,
|
|
// 小尺寸(微信/QQ 二次压缩后)最容易出现「扫不动」。同时必须把目标尺寸用满,
|
|
// 否则模块像素偏小,实际可扫距离大幅缩水。
|
|
func TestGenerateQRIsModuleAligned(t *testing.T) {
|
|
const url = "https://box.shiroko.one/_/tamakyi"
|
|
for _, size := range []int{QRCodeSize, QQRSize} {
|
|
img, err := generateQR(url, nil, size)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
probe, err := qrcode.New(url, qrcode.High)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probe.DisableBorder = true
|
|
modules := len(probe.Bitmap())
|
|
perModule := size / modules
|
|
want := modules * perModule
|
|
if got := img.Bounds().Dx(); got != want {
|
|
t.Fatalf("size %d: got width %d, want %d (%d modules x %dpx)", size, got, want, modules, perModule)
|
|
}
|
|
if got := img.Bounds().Dy(); got != want {
|
|
t.Fatalf("size %d: got height %d, want %d", size, got, want)
|
|
}
|
|
// 至少用掉目标尺寸的 80%,防止静默区/留白再次吃掉模块像素
|
|
if float64(want) < 0.8*float64(size) {
|
|
t.Fatalf("size %d: symbol only %dpx, wastes %.0f%% of the target", size, want, 100*(1-float64(want)/float64(size)))
|
|
}
|
|
|
|
// 逐行扫描:所有黑白游程都必须是模块宽度的整数倍
|
|
for _, y := range []int{want / 2, want / 3} {
|
|
runLen := 0
|
|
for x := 0; x < want; x++ {
|
|
r, _, _, _ := img.At(x, y).RGBA()
|
|
black := r < 0x8000
|
|
if x == 0 {
|
|
runLen = 1
|
|
continue
|
|
}
|
|
prevR, _, _, _ := img.At(x-1, y).RGBA()
|
|
if (prevR < 0x8000) != black {
|
|
if runLen%perModule != 0 {
|
|
t.Fatalf("size %d: run of %dpx at x=%d is not a multiple of module %d",
|
|
size, runLen, x-runLen, perModule)
|
|
}
|
|
runLen = 1
|
|
continue
|
|
}
|
|
runLen++
|
|
}
|
|
if runLen%perModule != 0 {
|
|
t.Fatalf("size %d: trailing run %dpx not a multiple of %d", size, runLen, perModule)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 模块像素是扫码能力的唯一硬指标:实测 5px/模块的卡缩到 320 宽仍可解码,
|
|
// 4px 时缩到 480 就解不出。这里锁住常见链接长度下的模块尺寸。
|
|
func TestGenerateQRModuleSizeAtLeast5(t *testing.T) {
|
|
for _, url := range []string{
|
|
"https://box.shiroko.one/_/tamakyi",
|
|
"https://box-dev-api.337845818.best/_/tbunread1022181904",
|
|
"https://box.shiroko.one/_/a-rather-long-handle-name",
|
|
} {
|
|
img, err := generateQR(url, nil, QRCodeSize)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probe, err := qrcode.New(url, qrcode.High)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probe.DisableBorder = true
|
|
mods := len(probe.Bitmap())
|
|
if per := img.Bounds().Dx() / mods; per < 5 {
|
|
t.Errorf("%s: %d modules in %dpx -> %dpx/module, want >=5",
|
|
url, mods, img.Bounds().Dx(), per)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 单题卡的二维码卡片:模块必须够大、整块落在卡内、提示语不越界。
|
|
func TestQuestionQRCardLayout(t *testing.T) {
|
|
requireFont(t)
|
|
saved := conf.App.ExternalURL
|
|
conf.App.ExternalURL = "https://box-dev-api.337845818.best"
|
|
t.Cleanup(func() { conf.App.ExternalURL = saved })
|
|
|
|
user := &db.User{Domain: "tbunread1022181904"}
|
|
question := &db.Question{ID: 113}
|
|
question.UserID = user.ID
|
|
|
|
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
|
|
|
fctx := freetype.NewContext()
|
|
fctx.SetFont(requireFont(t))
|
|
fctx.SetDPI(72)
|
|
if err := drawQuestionQRCard(canvas, user, question, nil, fctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 二维码:着墨边界必须落在卡片内且四周留白 ≥20px,模块 ≥5px
|
|
card := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
|
|
minX, minY, maxX, maxY := CardWidth, CardHeight, -1, -1
|
|
for y := card.Min.Y; y < card.Min.Y+220; y++ {
|
|
for x := card.Min.X; x < card.Max.X; x++ {
|
|
if r, _, _, _ := canvas.At(x, y).RGBA(); r < 0x4000 {
|
|
if x < minX {
|
|
minX = x
|
|
}
|
|
if y < minY {
|
|
minY = y
|
|
}
|
|
if x > maxX {
|
|
maxX = x
|
|
}
|
|
if y > maxY {
|
|
maxY = y
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if maxX < 0 {
|
|
t.Fatal("nothing drawn in the QR card")
|
|
}
|
|
size := maxX - minX + 1
|
|
if size != maxY-minY+1 {
|
|
t.Fatalf("QR is not square: %dx%d", size, maxY-minY+1)
|
|
}
|
|
if per := size / qrModules(t, conf.App.ExternalURL+"/_/"+user.Domain+"/113"); per < 5 {
|
|
t.Errorf("QR module is %dpx, want >=5", per)
|
|
}
|
|
for _, gap := range []struct {
|
|
name string
|
|
got, limit int
|
|
}{
|
|
{"left", minX - card.Min.X, 20},
|
|
{"right", card.Max.X - maxX - 1, 20},
|
|
{"top", minY - card.Min.Y, 20},
|
|
{"bottom", card.Max.Y - maxY - 1, 20},
|
|
} {
|
|
if gap.got < gap.limit {
|
|
t.Errorf("%s margin %dpx < %dpx", gap.name, gap.got, gap.limit)
|
|
}
|
|
}
|
|
|
|
if os.Getenv("TEST_PREVIEW") != "" {
|
|
outDir := os.Getenv("TEST_PREVIEW_DIR")
|
|
if outDir == "" {
|
|
outDir = filepath.Join("..", "..", "outputs", "sharecard-preview")
|
|
}
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Create(filepath.Join(outDir, "question-card.png"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := png.Encode(f, canvas); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
func qrModules(t *testing.T, content string) int {
|
|
t.Helper()
|
|
q, err := qrcode.New(content, qrcode.High)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q.DisableBorder = true
|
|
return len(q.Bitmap())
|
|
}
|
|
|
|
// 缓存 key 必须覆盖所有画进图里的资料:漏一项就会出现「改了资料,卡片还是一小时旧图」。
|
|
func TestShareCardCacheKeysCoverDrawnFields(t *testing.T) {
|
|
base := db.User{Domain: "d", Name: "n", Intro: "i", Avatar: "a", Background: "b",
|
|
Acfunlink: "1", Bililink: "2", Weibolink: "3", Bloglink: "4", Xiaohongshulink: "5"}
|
|
baseKey := shareCardCacheKey(&base)
|
|
|
|
userMuts := []struct {
|
|
field string
|
|
apply func(u *db.User)
|
|
}{
|
|
{"domain", func(u *db.User) { u.Domain = "x" }},
|
|
{"name", func(u *db.User) { u.Name = "x" }},
|
|
{"intro", func(u *db.User) { u.Intro = "x" }},
|
|
{"avatar", func(u *db.User) { u.Avatar = "x" }},
|
|
{"background", func(u *db.User) { u.Background = "x" }},
|
|
{"acfun", func(u *db.User) { u.Acfunlink = "x" }},
|
|
{"bili", func(u *db.User) { u.Bililink = "x" }},
|
|
{"weibo", func(u *db.User) { u.Weibolink = "x" }},
|
|
{"blog", func(u *db.User) { u.Bloglink = "x" }},
|
|
{"xiaohongshu", func(u *db.User) { u.Xiaohongshulink = "x" }},
|
|
}
|
|
for _, m := range userMuts {
|
|
u := base
|
|
m.apply(&u)
|
|
if shareCardCacheKey(&u) == baseKey {
|
|
t.Errorf("user.%s is drawn on the card but not in shareCardCacheKey", m.field)
|
|
}
|
|
}
|
|
|
|
question := db.Question{Content: "c", Answer: "a"}
|
|
question.ID = 7
|
|
qKey := questionShareCardCacheKey(&base, &question)
|
|
questionMuts := []struct {
|
|
field string
|
|
apply func(u *db.User, q *db.Question)
|
|
}{
|
|
{"question.ID", func(u *db.User, q *db.Question) { q.ID++ }},
|
|
{"question.Content", func(u *db.User, q *db.Question) { q.Content = "x" }},
|
|
{"question.Answer", func(u *db.User, q *db.Question) { q.Answer = "x" }},
|
|
{"user.Domain", func(u *db.User, q *db.Question) { u.Domain = "x" }},
|
|
{"user.Name", func(u *db.User, q *db.Question) { u.Name = "x" }},
|
|
{"user.Avatar", func(u *db.User, q *db.Question) { u.Avatar = "x" }},
|
|
{"user.Background", func(u *db.User, q *db.Question) { u.Background = "x" }},
|
|
}
|
|
for _, m := range questionMuts {
|
|
u, q := base, question
|
|
m.apply(&u, &q)
|
|
if questionShareCardCacheKey(&u, &q) == qKey {
|
|
t.Errorf("%s is drawn on the question card but not in questionShareCardCacheKey", m.field)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。
|
|
func TestShareCardLinkPreview(t *testing.T) {
|
|
if os.Getenv("TEST_PREVIEW") == "" {
|
|
t.Skip("set TEST_PREVIEW=1 to write preview images")
|
|
}
|
|
requireFont(t)
|
|
outDir := os.Getenv("TEST_PREVIEW_DIR")
|
|
if outDir == "" {
|
|
outDir = filepath.Join("..", "..", "outputs", "sharecard-preview")
|
|
}
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := map[string]string{
|
|
"short": "box.shiroko.one/_/tamakyi",
|
|
"dev": "box-dev-api.337845818.best/_/tamakyi",
|
|
"long": "ask.some-quite-long-domain-name.example.com/_/tamakyi",
|
|
"verylong": "questions.tamabox-community.example.org/anon/box/_/a-rather-long-handle-name",
|
|
}
|
|
for name, display := range cases {
|
|
canvas := image.NewRGBA(image.Rect(0, 0, QRCardW, QRCardH))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{color.NRGBA{246, 246, 246, 255}}, image.Point{}, draw.Src)
|
|
drawRoundedRect(canvas, canvas.Bounds(), ColorWhite, 18)
|
|
|
|
fctx := freetype.NewContext()
|
|
fctx.SetFont(requireFont(t))
|
|
fctx.SetDPI(72)
|
|
|
|
lines, size := layoutLink(display)
|
|
const qrToHintGap, hintLineHeight, hintToSepGap, sepHeight, sepToLinkGap, linkLineHeight = 24, 17, 20, 1, 20, 16
|
|
|
|
qr, err := generateQR("https://"+display, nil, QRCodeSize)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
qrSize := qr.Bounds().Dx()
|
|
total := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(lines)
|
|
startY := (QRCardH - total) / 2
|
|
|
|
draw.Draw(canvas, image.Rect((QRCardW-qrSize)/2, startY, (QRCardW+qrSize)/2, startY+qrSize), qr, image.Point{}, draw.Over)
|
|
|
|
hintY := startY + qrSize + qrToHintGap + hintLineHeight
|
|
drawTextCentered(canvas, "扫码向我匿名提问", QRCardW/2, hintY, FontSizeQRHint, ColorHintGray, fctx)
|
|
sepY := hintY + hintToSepGap
|
|
drawLine(canvas, QRCardW/2-90, sepY, QRCardW/2+90, sepY, color.NRGBA{200, 200, 200, 180})
|
|
linkY := sepY + sepToLinkGap + linkLineHeight
|
|
for i, line := range lines {
|
|
drawTextCentered(canvas, line, QRCardW/2, linkY+i*linkLineHeight, size, ColorDarkText, fctx)
|
|
}
|
|
|
|
f, err := os.Create(filepath.Join(outDir, name+".png"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := png.Encode(f, canvas); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
}
|
|
}
|