原 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(永远返回第一个端口,从不等连接结果)。
298 lines
7.3 KiB
Go
298 lines
7.3 KiB
Go
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}
|
||
}
|