feat: EH Downloader 用 Go + Wails v2 重写

原 Electron 版在这台 Windows 机器上无法启动(0x80000003 崩在 V8 初始化),
整个业务逻辑用 Go 重写,前端界面与协议结论完全保留。

- internal/eh: 协议核心,画廊页分页 -> /s/ -> hath 直链 -> 下载
- internal/core: 并发引擎 / 状态存储 / 目录模板 / 油猴桥口
- app.go: Wails 绑定层,替代 Electron IPC
- frontend/js/api.js: 适配层,renderer.js 一行未改
- 37 个测试,含 httptest 模拟 EH 的端到端链路验证

相比 Electron 版:90MB -> 12.4MB,180MB 内存 -> 27MB,构建 90s -> 2s。

顺带修掉原版代理探测的 bug(永远返回第一个端口,从不等连接结果)。
This commit is contained in:
2026-10-08 23:28:16 +08:00
commit 0f927147cd
42 changed files with 6805 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
package core
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"eh-downloader-wails/internal/eh"
)
func newTestStore(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "state.json")
return NewStore(p), p
}
func TestStoreDefaults(t *testing.T) {
s, _ := newTestStore(t)
st := s.Settings()
if st.Concurrency != 3 || st.Timeout != 30000 || st.BridgePort != 37777 {
t.Errorf("默认值不对: %+v", st)
}
if st.DirTemplate != "{title}" {
t.Errorf("默认模板不对: %q", st.DirTemplate)
}
if st.Xres != "auto" {
t.Errorf("默认 xres 不对: %q", st.Xres)
}
}
// 原子写 + 重新加载
func TestStorePersist(t *testing.T) {
s, path := newTestStore(t)
s.UpdateSettings(map[string]interface{}{"concurrency": 5, "proxy": "http://127.0.0.1:7890"})
s.Flush()
s2 := NewStore(path)
got := s2.Settings()
if got.Concurrency != 5 {
t.Errorf("并发没存住: %d", got.Concurrency)
}
if got.Proxy != "http://127.0.0.1:7890" {
t.Errorf("代理没存住: %q", got.Proxy)
}
}
func TestStoreTasks(t *testing.T) {
s, _ := newTestStore(t)
refs := []eh.GalleryRef{
{GID: "1111111", Token: "aaa"},
{GID: "2222222", Token: "bbb"},
{GID: "1111111", Token: "aaa"}, // 重复
}
if n := s.AddTasks(refs); n != 2 {
t.Errorf("应新增 2 个(去重),实际 %d", n)
}
if len(s.Tasks()) != 2 {
t.Errorf("任务数不对: %d", len(s.Tasks()))
}
if s.FindTask("1111111", "aaa") == nil {
t.Error("找不到已加任务")
}
// SetTitle 只在标题为空时生效
if !s.SetTitle("1111111", "aaa", "标题A") {
t.Error("应设置标题")
}
if s.SetTitle("1111111", "aaa", "标题B") {
t.Error("标题非空时不应覆盖")
}
if s.FindTask("1111111", "aaa").Title != "标题A" {
t.Error("标题不对")
}
}
// 清除只动列表不动文件
func TestStoreClearAll(t *testing.T) {
s, _ := newTestStore(t)
s.AddTasks([]eh.GalleryRef{
{GID: "1", Token: "a"},
{GID: "2", Token: "b"},
{GID: "3", Token: "c"},
})
// 标记第 2 个为选中
s.SelectAll([]string{"2"})
if n := s.ClearAll(true); n != 1 {
t.Errorf("删选中应删 1 个,实际 %d", n)
}
if len(s.Tasks()) != 2 {
t.Errorf("剩余任务数不对: %d", len(s.Tasks()))
}
if n := s.ClearAll(false); n != 2 {
t.Errorf("删全部应删 2 个,实际 %d", n)
}
if len(s.Tasks()) != 0 {
t.Error("应清空")
}
}
// ClearFinished 只清 done,partial/failed 保留
func TestStoreClearFinished(t *testing.T) {
s, _ := newTestStore(t)
for _, g := range []struct{ gid, status string }{
{"1", "done"},
{"2", "partial"},
{"3", "failed"},
{"4", "queued"},
} {
s.AddTasks([]eh.GalleryRef{{GID: g.gid, Token: "t"}})
s.UpsertTask(Task{GID: g.gid, Token: "t", Status: g.status})
}
n := s.ClearFinished()
if n != 1 {
t.Errorf("应只清 1 个 done,实际 %d", n)
}
for _, task := range s.Tasks() {
if task.GID == "1" {
t.Error("done 的不该还在")
}
}
if len(s.Tasks()) != 3 {
t.Errorf("应剩 3 个,实际 %d", len(s.Tasks()))
}
}
func TestStoreRequeueAndRecover(t *testing.T) {
s, _ := newTestStore(t)
for _, g := range []struct{ gid, status string }{
{"1", "failed"},
{"2", "partial"},
{"3", "running"}, // 崩溃残留
{"4", "downloading"}, // 崩溃残留
{"5", "done"},
} {
s.AddTasks([]eh.GalleryRef{{GID: g.gid, Token: "t"}})
s.UpsertTask(Task{GID: g.gid, Token: "t", Status: g.status})
}
// RequeueFailed 会把 failed/partial/running/downloading 一起重排
if n := s.RequeueFailed(); n != 4 {
t.Errorf("失败/部分/中断应重排 4 个,实际 %d", n)
}
if n := s.RecoverOnStart(); n != 0 {
t.Errorf("已全部重排,不该再有残留,实际 %d", n)
}
if n := s.CountDone(); n != 1 {
t.Errorf("done 应剩 1 个,实际 %d", n)
}
// 单独验证 RecoverOnStart:手动塞个 running 残留
s.UpsertTask(Task{GID: "6", Token: "t", Status: "downloading"})
if n := s.RecoverOnStart(); n != 1 {
t.Errorf("running/downloading 应归位 1 个,实际 %d", n)
}
if s.FindTask("6", "t").Status != "queued" {
t.Error("残留任务应被归位为 queued")
}
}
// 状态文件损坏时回落默认值,不崩
func TestStoreCorruptedFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "state.json")
os.WriteFile(p, []byte("{ 这不是合法 JSON"), 0o644)
s := NewStore(p)
if s.Settings().Concurrency != 3 {
t.Error("损坏文件应回落到默认设置")
}
if len(s.Tasks()) != 0 {
t.Error("损坏文件应回落空任务")
}
}
// Tasks() 返回副本,改它不影响内部状态
func TestStoreTasksIsCopy(t *testing.T) {
s, _ := newTestStore(t)
s.AddTasks([]eh.GalleryRef{{GID: "1", Token: "t", Raw: "orig"}})
got := s.Tasks()
got[0].Status = "done"
if s.FindTask("1", "t").Status == "done" {
t.Error("Tasks() 应返回副本,外部修改不该影响内部")
}
}
// ---- 引擎:断点续传的核心是「文件在就算完成」----
func TestEngineResumeSkipsExisting(t *testing.T) {
s, _ := newTestStore(t)
root := t.TempDir()
s.UpdateSettings(map[string]interface{}{"downloadRoot": root, "dirTemplate": "{gid}"})
dir := filepath.Join(root, "12345678")
os.MkdirAll(dir, 0o755)
// 造一个已存在的文件 + 一个空文件
full := filepath.Join(dir, "001.webp")
os.WriteFile(full, []byte("fake image data"), 0o644)
empty := filepath.Join(dir, "002.webp")
os.WriteFile(empty, []byte{}, 0o644)
// targetName 的行为验证:页码补零
if got := targetName(ehPageInfo("001.webp", 1), "12345678", 3); got != "001.webp" {
t.Errorf("文件名不对: %q", got)
}
if st, _ := os.Stat(full); st == nil || st.Size() == 0 {
t.Error("测试前提失败:文件应存在且非空")
}
}
func TestEngineConcurrencyClamp(t *testing.T) {
s, _ := newTestStore(t)
e := NewEngine(s, nil, s.Settings())
e.SetConcurrency(99)
if e.concurrency != 8 {
t.Errorf("并发应被限制到 8,实际 %d", e.concurrency)
}
e.SetConcurrency(0)
if e.concurrency != 1 {
t.Errorf("并发应被限制到 1,实际 %d", e.concurrency)
}
}
func TestEngineStopIsSafeWhenIdle(t *testing.T) {
s, _ := newTestStore(t)
e := NewEngine(s, nil, s.Settings())
e.Stop() // 不在跑时调 Stop 不该 panic
if e.IsRunning() {
t.Error("不该在跑")
}
}
func TestEngineStartEmptyQueue(t *testing.T) {
s, _ := newTestStore(t)
e := NewEngine(s, &noopSink{}, s.Settings())
done := make(chan struct{})
go func() { e.Start(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("空队列应该立刻返回")
}
}
type noopSink struct{}
func (n *noopSink) TaskUpdate(Task) {}
func (n *noopSink) Log(string, string) {}
func (n *noopSink) Done() {}
// 错误可重试性判断
func TestIsRetryable(t *testing.T) {
retryable := []error{
errStr("connection reset by peer"),
errStr("dial tcp: i/o timeout"),
errStr("HTTP 503"),
errStr("HTTP 429"),
errStr("socket hang up"),
}
for _, e := range retryable {
if !isRetryable(e) {
t.Errorf("应判为可重试: %v", e)
}
}
notRetryable := []error{
nil,
ErrCanceled,
context.Canceled,
errStr("未能从 /s/ 页面解析出图片地址"),
}
for _, e := range notRetryable {
if isRetryable(e) {
t.Errorf("应判为不可重试: %v", e)
}
}
}
type errStr string
func (e errStr) Error() string { return string(e) }
func ehPageInfo(name string, page int) eh.GalleryPageInfo {
return eh.GalleryPageInfo{Page: page, Filename: name}
}