Files
cmsp/internal/task/task_test.go
T
QiuSWandClaude Opus 5 f79208cc5c feat: 批量取视频,队列、进度与全局停止门 (#14)
由 Codex (gpt-5.6-sol) 实施,Claude 审核。MVP2 最后一块。

internal/task/task.go
- 串行阶段(Prepare)一次一个商品,全程持有同一个 CDP:
  登录深度检查 → 图搜 → 逐个同款提取视频地址。
  并发只作用于下载阶段(纯 HTTP + ffprobe,不碰 CDP)。
  这样既避免多协程踩同一个浏览器页面,也不抬高淘宝风控概率。
- 登录失效走独立的 ErrLoginRequired,触发后立即终止整批,
  不再启动后续商品,不自动重试也不自动重新登录
- 其它错误属于单商品失败,记录后继续处理下一个
- 断点复用商品自身的 download_status,不另建断点文件或断点表,
  因此不会出现断点与数据库不一致

app.go 抽出单商品核心供两处复用,未复制逻辑。

审核补测(跑完删除,未入库),五项全部通过:
- 登录失效发生在第 3 个商品时,后续 D/E/F 一个都没被处理
- Concurrency 设为 4 时 Prepare 峰值仍为 1,串行确实生效
- 下载阶段峰值恰为 2,并发上限真的起作用且确实并行
- 连续 5 个普通失败仍跑完全部,状态为 completed
- 停止后可以重新启动,不会被永久锁死

硬性验收项全部通过:前端无 window.go 等绕过生成绑定的写法
(前三个工单连续出现,本次未再出现);internal 未 import wails;
无新增依赖。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 10:58:55 +08:00

261 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package task
import (
"context"
"errors"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
func Test正常跑完N个商品(t *testing.T) {
var mu sync.Mutex
var doneValues []int
runner := newTestRunner(Options{
OnProgress: func(progress Progress) {
mu.Lock()
doneValues = append(doneValues, progress.Done)
mu.Unlock()
},
})
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
progress := waitForState(t, runner, StateCompleted)
if progress.Total != 3 || progress.Done != 3 {
t.Fatalf("完成进度错误:%+v", progress)
}
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(doneValues, []int{1, 2, 3}) {
t.Fatalf("进度没有从 0 依次推进到 N:%v", doneValues)
}
}
func Test中途停止后不再启动剩余商品(t *testing.T) {
started := make(chan string, 3)
release := make(chan struct{})
runner := newTestRunner(Options{
Prepare: func(_ context.Context, product Product) (Work, error) {
started <- product.ID
if product.ID == "1" {
<-release
}
return Work{}, nil
},
})
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
if got := <-started; got != "1" {
t.Fatalf("首个商品错误:%s", got)
}
runner.Stop()
close(release)
progress := waitForState(t, runner, StateStopped)
if progress.Done != 1 {
t.Fatalf("当前商品应处理完再停止:%+v", progress)
}
select {
case id := <-started:
t.Fatalf("停止后仍启动了商品:%s", id)
default:
}
}
func Test登录失效立即触发全局停止门(t *testing.T) {
var processed []string
var mu sync.Mutex
runner := newTestRunner(Options{
Prepare: func(_ context.Context, product Product) (Work, error) {
mu.Lock()
processed = append(processed, product.ID)
mu.Unlock()
if product.ID == "2" {
return Work{}, errors.Join(ErrLoginRequired, errors.New("深度检查不通过"))
}
return Work{}, nil
},
})
if err := runner.Start(context.Background(), []string{"1", "2", "3", "4"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
progress := waitForState(t, runner, StateLoginRequired)
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(processed, []string{"1", "2"}) {
t.Fatalf("登录失效后仍处理了后续商品:%v", processed)
}
if progress.Done != 1 || progress.Failed != 0 {
t.Fatalf("登录失效商品不能记为单商品失败:%+v", progress)
}
}
func Test单商品失败后继续处理后续商品(t *testing.T) {
var processed []string
var mu sync.Mutex
runner := newTestRunner(Options{
Prepare: func(_ context.Context, product Product) (Work, error) {
mu.Lock()
processed = append(processed, product.ID)
mu.Unlock()
if product.ID == "2" {
return Work{}, errors.New("图搜失败")
}
return Work{}, nil
},
})
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
progress := waitForState(t, runner, StateCompleted)
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(processed, []string{"1", "2", "3"}) {
t.Fatalf("单商品失败后没有继续:%v", processed)
}
if progress.Failed != 1 || progress.Done != 3 {
t.Fatalf("失败计数错误:%+v", progress)
}
}
func Test已有任务运行时拒绝再次启动(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{})
runner := newTestRunner(Options{
Prepare: func(context.Context, Product) (Work, error) {
close(started)
<-release
return Work{}, nil
},
})
if err := runner.Start(context.Background(), []string{"1"}); err != nil {
t.Fatalf("首次启动失败:%v", err)
}
<-started
if err := runner.Start(context.Background(), []string{"2"}); err == nil || err.Error() != "已有任务在运行" {
t.Fatalf("重复启动应返回指定中文错误,实际:%v", err)
}
close(release)
waitForState(t, runner, StateCompleted)
}
func Test已完成和无同款商品不进入队列(t *testing.T) {
statuses := map[string]Product{
"done": {ID: "done", DownloadStatus: "done"},
"none": {ID: "none", VideoStatus: "none"},
"todo": {ID: "todo"},
}
var processed []string
var mu sync.Mutex
runner := newTestRunner(Options{
Load: func(_ context.Context, id string) (Product, error) { return statuses[id], nil },
Prepare: func(_ context.Context, product Product) (Work, error) {
mu.Lock()
processed = append(processed, product.ID)
mu.Unlock()
return Work{}, nil
},
})
if err := runner.Start(context.Background(), []string{"done", "none", "todo"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
progress := waitForState(t, runner, StateCompleted)
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(processed, []string{"todo"}) {
t.Fatalf("断点跳过逻辑错误:%v", processed)
}
if progress.Total != 1 || progress.Done != 1 {
t.Fatalf("跳过后的总数错误:%+v", progress)
}
}
func Test下载并发数不超过配置值(t *testing.T) {
var active int32
var maximum int32
jobs := make([]DownloadFunc, 8)
for index := range jobs {
jobs[index] = func(context.Context) error {
current := atomic.AddInt32(&active, 1)
for {
old := atomic.LoadInt32(&maximum)
if current <= old || atomic.CompareAndSwapInt32(&maximum, old, current) {
break
}
}
time.Sleep(15 * time.Millisecond)
atomic.AddInt32(&active, -1)
return nil
}
}
runner := newTestRunner(Options{
Concurrency: 2,
Prepare: func(context.Context, Product) (Work, error) {
return Work{Downloads: jobs}, nil
},
})
if err := runner.Start(context.Background(), []string{"1"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
progress := waitForState(t, runner, StateCompleted)
if maximum != 2 {
t.Fatalf("下载同时运行数应为 2,实际为 %d", maximum)
}
if progress.Downloaded != len(jobs) {
t.Fatalf("下载计数错误:%+v", progress)
}
}
func Test每处理完一个商品调用一次进度回调(t *testing.T) {
var calls int32
runner := newTestRunner(Options{
OnProgress: func(Progress) { atomic.AddInt32(&calls, 1) },
})
if err := runner.Start(context.Background(), []string{"1", "2", "3", "4"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
waitForState(t, runner, StateCompleted)
if calls != 4 {
t.Fatalf("进度回调次数应等于处理商品数,实际为 %d", calls)
}
}
func newTestRunner(override Options) *Runner {
options := Options{
Concurrency: 1,
Load: func(_ context.Context, id string) (Product, error) {
return Product{ID: id, Name: "商品" + id}, nil
},
Prepare: func(context.Context, Product) (Work, error) { return Work{}, nil },
}
if override.Load != nil {
options.Load = override.Load
}
if override.Prepare != nil {
options.Prepare = override.Prepare
}
if override.Concurrency > 0 {
options.Concurrency = override.Concurrency
}
options.OnProgress = override.OnProgress
options.OnFinished = override.OnFinished
return NewRunner(options)
}
func waitForState(t *testing.T, runner *Runner, state string) Progress {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
progress := runner.Snapshot()
if progress.State == state {
return progress
}
time.Sleep(time.Millisecond)
}
t.Fatalf("等待任务状态 %s 超时,当前:%+v", state, runner.Snapshot())
return Progress{}
}