现象:默认模板 {title} 本该只出一层目录,但标题里带 | / : 的画廊
会凭空多出一层文件夹(6 个里 5 个中招)。
根因:把「非法文件名字符集」当成了「目录分隔符集」。两者只有 \ 和 /
重叠,剩下 : * ? " < > | 这 6 个字符被完全误当成目录分隔符。
更糟的是展开顺序——旧代码先把占位符全部替换完再切分,切分对象是
替换后的完整字符串,模板里的分隔符和值里的字符混在一起根本分不开。
EH 标题惯例是「原文 | 译名」,竖线极常见,所以中招率很高。
改动:改为先按模板文本切段(新增 reSep),再逐段展开占位符;
值里的非法字符一律交给 SanitizeSegment 清洗成 _,不再产生层级。
模板里自己写的 / 仍然正常分层,{artist}/{title} 行为不变。
顺带一提:值里的非法字符以前是「变成目录」,现在才是本该有的
「清洗成 _」——两处回归测试都锁住了。
249 lines
7.1 KiB
Go
249 lines
7.1 KiB
Go
package core
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"eh-downloader-wails/internal/eh"
|
|
)
|
|
|
|
func sampleGallery() *eh.Gallery {
|
|
return &eh.Gallery{
|
|
GID: "4239274",
|
|
Title: "[Noxe] 0820 Qingxiao [AI Generated]",
|
|
Meta: eh.GalleryMeta{
|
|
Artist: "Noxe",
|
|
Group: "CriticalHAI",
|
|
Series: "wuthering waves",
|
|
Category: "misc",
|
|
Lang: "chinese",
|
|
Uploader: "velius11",
|
|
Tags: []string{"qingxiao", "rover", "big breasts", "x-ray", "ai generated"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestBuildDir(t *testing.T) {
|
|
g := sampleGallery()
|
|
tests := []struct {
|
|
tpl string
|
|
want string
|
|
}{
|
|
{"{title}", "[Noxe] 0820 Qingxiao [AI Generated]"},
|
|
{"{artist}/{title}", `Noxe/[Noxe] 0820 Qingxiao [AI Generated]`},
|
|
{"{group}/{title}", `CriticalHAI/[Noxe] 0820 Qingxiao [AI Generated]`},
|
|
{"{category}/{group}/{title}", `misc/CriticalHAI/[Noxe] 0820 Qingxiao [AI Generated]`},
|
|
{"{tags:2}/{artist}/{title}", `qingxiao_rover/Noxe/[Noxe] 0820 Qingxiao [AI Generated]`},
|
|
{"{tags}/{artist}/{title}", `qingxiao_rover_big breasts_x-ray_ai generated/Noxe/[Noxe] 0820 Qingxiao [AI Generated]`},
|
|
{"{tag1}-{tag2}", "qingxiao-rover"},
|
|
{"{artist} - {gid} - {title}", "Noxe - 4239274 - [Noxe] 0820 Qingxiao [AI Generated]"},
|
|
{"{gid}", "4239274"},
|
|
{"{lang}/{title}", `chinese/[Noxe] 0820 Qingxiao [AI Generated]`},
|
|
{"{unknown}/{title}", `{unknown}/[Noxe] 0820 Qingxiao [AI Generated]`}, // 未知占位符原样保留
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.tpl, func(t *testing.T) {
|
|
got := BuildDir(tt.tpl, g)
|
|
// 期望值用 / 书写,再换成本机路径分隔符(Windows 是 \)
|
|
want := strings.ReplaceAll(tt.want, "/", string(filepath.Separator))
|
|
if got != want {
|
|
t.Errorf("\n got: %q\nwant: %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 空模板回落到 {title}
|
|
func TestBuildDirDefaults(t *testing.T) {
|
|
g := sampleGallery()
|
|
for _, tpl := range []string{"", " "} {
|
|
if got := BuildDir(tpl, g); !strings.Contains(got, "Noxe") {
|
|
t.Errorf("空模板应回落到标题, got %q", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 占位符「值」里的非法字符只能清洗成 _,绝不能变成目录层级。
|
|
//
|
|
// 回归 2026-10 的线上问题:EH 标题惯例是「原文 | 译名」,
|
|
// 旧实现先替换占位符再按 [\\/:*?"<>|] 切分,于是标题里一个竖线
|
|
// 就凭空多出一层文件夹(6 个里 2 个中招)。
|
|
func TestBuildDirValueIllegalCharsDoNotNest(t *testing.T) {
|
|
sep := string(filepath.Separator)
|
|
|
|
tests := []struct {
|
|
name string
|
|
tpl string
|
|
title string
|
|
want string // 用 / 书写,断言前会换成本机分隔符
|
|
}{
|
|
{
|
|
name: "竖线(EH 标题惯例:原文 | 译名)",
|
|
tpl: "{title}",
|
|
title: "[Warashibe] Mesuana Hojiri Enkou | 牝穴開發援助交尾 [Chinese]",
|
|
want: "[Warashibe] Mesuana Hojiri Enkou _ 牝穴開發援助交尾 [Chinese]",
|
|
},
|
|
{
|
|
name: "斜杠",
|
|
tpl: "{title}",
|
|
title: "[Circle] AAA/BBB Collection",
|
|
want: "[Circle] AAA_BBB Collection",
|
|
},
|
|
{
|
|
name: "反斜杠",
|
|
tpl: "{title}",
|
|
title: `[Circle] AAA\BBB Collection`,
|
|
want: "[Circle] AAA_BBB Collection",
|
|
},
|
|
{
|
|
name: "冒号",
|
|
tpl: "{title}",
|
|
title: "Dai Ni Miyasaka Byouin 3 karte: Tsuyo Tanba",
|
|
want: "Dai Ni Miyasaka Byouin 3 karte_ Tsuyo Tanba",
|
|
},
|
|
{
|
|
name: "问号星号双引号尖括号",
|
|
tpl: "{title}",
|
|
title: `a?b*c"d<e>f|g`,
|
|
want: `a_b_c_d_e_f_g`,
|
|
},
|
|
{
|
|
name: "模板里的分隔符仍然生效(多级目录要保留)",
|
|
tpl: "{artist}/{title}",
|
|
title: "Enkou | 牝穴",
|
|
want: "Noxe/Enkou _ 牝穴",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
g := &eh.Gallery{GID: "3492157", Title: tt.title}
|
|
g.Meta.Artist = "Noxe"
|
|
got := BuildDir(tt.tpl, g)
|
|
want := strings.ReplaceAll(tt.want, "/", sep)
|
|
if got != want {
|
|
t.Errorf("\n got: %q\nwant: %q", got, want)
|
|
}
|
|
// 值里的非法字符不该产生额外层级:段数必须和模板段数一致
|
|
if n := strings.Count(got, sep); n != strings.Count(tt.tpl, "/") {
|
|
t.Errorf("层级数不对: got %q (%d 层)", got, n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 占位符兜底值
|
|
func TestBuildDirFallbacks(t *testing.T) {
|
|
g := &eh.Gallery{GID: "12345678", Title: "Plain Title"}
|
|
got := BuildDir("{artist}/{group}/{series}/{category}/{lang}/{uploader}", g)
|
|
for _, want := range []string{"未知作者", "未知社团", "无系列", "未分类", "nolang", "unknown"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("缺兜底值 %q: %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 路径穿越必须被挡住
|
|
func TestSanitizeSegmentSecurity(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"..", "_"},
|
|
{".", "_"},
|
|
{"../../etc/passwd", ".._.._etc_passwd"},
|
|
{`a/b\c:d*e?f"g<h>i|j`, "a_b_c_d_e_f_g_h_i_j"},
|
|
{"CON", "_CON"}, // Windows 保留设备名
|
|
{"nul.txt", "_nul.txt"}, // 保留名带扩展也不行
|
|
{"正常名字", "正常名字"},
|
|
{"trailing dots...", "trailing dots"},
|
|
{" spaced ", "spaced"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := SanitizeSegment(c.in, "fb"); got != c.want {
|
|
t.Errorf("SanitizeSegment(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 超长截断
|
|
func TestSanitizeSegmentTruncate(t *testing.T) {
|
|
long := strings.Repeat("长", 300)
|
|
got := SanitizeSegment(long, "fb")
|
|
if len([]rune(got)) != 110 {
|
|
t.Errorf("应截到 110 字,实际 %d", len([]rune(got)))
|
|
}
|
|
}
|
|
|
|
// 分隔符混用(用户可能用 / 或 \)
|
|
func TestBuildDirMixedSeparators(t *testing.T) {
|
|
g := sampleGallery()
|
|
for _, tpl := range []string{`{artist}/{title}`, `{artist}\{title}`, `{artist}//{title}`} {
|
|
got := BuildDir(tpl, g)
|
|
if strings.Contains(got, "/") || strings.Contains(got, "\\") && strings.Count(got, "\\") > 1 {
|
|
continue // 各平台分隔符不同,只要求不出现空段
|
|
}
|
|
if strings.Contains(got, "__") {
|
|
t.Errorf("出现空段: %q (tpl=%q)", got, tpl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListPlaceholders(t *testing.T) {
|
|
p := ListPlaceholders()
|
|
if len(p) == 0 {
|
|
t.Fatal("占位符列表不该为空")
|
|
}
|
|
found := false
|
|
for _, x := range p {
|
|
if x == "{tags:3}" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("缺 {tags:3}")
|
|
}
|
|
}
|
|
|
|
// ---- 代理探测:必须真的等连接结果(JS 版是坏的,永远返回 7890)----
|
|
|
|
func TestDetectLocalProxy(t *testing.T) {
|
|
// 起一个真实的监听端口
|
|
ln, err := net_listen_loopback()
|
|
if err != nil {
|
|
t.Skipf("无法监听端口,跳过: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
port := ln.port()
|
|
|
|
got := DetectLocalProxy([]int{port})
|
|
if got == "" {
|
|
t.Error("有端口在监听,应该探测得到")
|
|
return
|
|
}
|
|
if !strings.Contains(got, "127.0.0.1") {
|
|
t.Errorf("地址不对: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDetectLocalProxyNone(t *testing.T) {
|
|
// 用一个几乎肯定没人监听的端口
|
|
if got := DetectLocalProxy([]int{1, 2, 3}); got != "" {
|
|
t.Errorf("没端口监听时不该返回结果, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDefaultDownloadRoot(t *testing.T) {
|
|
root := DefaultDownloadRoot()
|
|
if root == "" {
|
|
t.Fatal("下载目录不该为空")
|
|
}
|
|
if !strings.HasSuffix(root, "Downloads") && !strings.Contains(root, "Downloads") {
|
|
t.Errorf("路径可疑: %q", root)
|
|
}
|
|
if _, err := os.Stat(root); err == nil {
|
|
// 目录存在也没关系,只确认路径合法
|
|
t.Logf("目录已存在: %s", root)
|
|
}
|
|
}
|