Files
eh-downloader/internal/core/paths_test.go
T
toor 77a422b9bf fix(core): BuildDir 只按模板里的分隔符分层
现象:默认模板 {title} 本该只出一层目录,但标题里带 | / : 的画廊
会凭空多出一层文件夹(6 个里 5 个中招)。

根因:把「非法文件名字符集」当成了「目录分隔符集」。两者只有 \ 和 /
重叠,剩下 : * ? " < > | 这 6 个字符被完全误当成目录分隔符。
更糟的是展开顺序——旧代码先把占位符全部替换完再切分,切分对象是
替换后的完整字符串,模板里的分隔符和值里的字符混在一起根本分不开。

EH 标题惯例是「原文 | 译名」,竖线极常见,所以中招率很高。

改动:改为先按模板文本切段(新增 reSep),再逐段展开占位符;
值里的非法字符一律交给 SanitizeSegment 清洗成 _,不再产生层级。
模板里自己写的 / 仍然正常分层,{artist}/{title} 行为不变。

顺带一提:值里的非法字符以前是「变成目录」,现在才是本该有的
「清洗成 _」——两处回归测试都锁住了。
2026-10-11 11:41:00 +08:00

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)
}
}