feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完

发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行
`标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块
「一轮把本机待发的号跑完」自动切号,一直发到发完。

· 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因),
  删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建)
· 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/
  循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口)
· 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**)
  → 抖音流程 → 标记结果;push_release 的 account 默认 name
· 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、
  「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签;
  device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**)
· 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备)
  + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来;
  推送时把"实际是谁发的"写进 posted_serial / posted_device_name
· 通知:新增 task.account.switch_failed / task.account.need_login
· schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at)
· 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY /
  DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env)
· 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
This commit is contained in:
2026-10-12 09:02:40 +08:00
parent 75bee21ffb
commit beda0f3779
37 changed files with 2615 additions and 1747 deletions
+46
View File
@@ -0,0 +1,46 @@
"""发布计划「按名字发」相关用例:一次跑完,汇总结果。
用法(项目根目录):
.venv/Scripts/python scripts/tests/run_all.py # Windows
.venv/bin/python scripts/tests/run_all.py # Linux/macOS
这些用例的**铁律**(与 doc/DEVELOPMENT.md 的测试约定一致):
· 一律**临时 SQLite + 临时目录**(`DATABASE_URL` / `DATA_VIDEO_DIR` 指向 tempfile 建的目录),
**绝不碰用户的真实库与真实素材目录**;
· 需要设备的地方一律用**假设备**(桩对象/stub),**不连真机、不下发 adb**;
· 每个用例自己退出码非 0 表示失败,本脚本据此汇总。
覆盖:
test_parse_name 文件名/标题行解析(`名字_日期_编号`、`标题 || 正文_...`、各种拒收)
test_upload_name 上传 + 配标题落库(不再依赖任何账号台账)
test_name_scope 读本机账号名字、按名字取计划、推送前核对
test_switch_account 切换账号(按名字点控件、回读校验、登录页停设备)
test_foreach_account 循环本机待发的号(一轮跑完、试过不回头、干净退出)
test_push_by_name 按名字找设备推送(matched/unmatched、缓存)
"""
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
CASES = ["test_parse_name.py", "test_upload_name.py", "test_name_scope.py",
"test_switch_account.py", "test_foreach_account.py", "test_push_by_name.py"]
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
failed = []
for name in CASES:
print("=" * 66)
print("▶", name)
print("=" * 66)
r = subprocess.run([sys.executable, os.path.join(HERE, name)])
if r.returncode != 0:
failed.append(name)
print()
print("=" * 66)
if failed:
print("❌ 失败 %d / %d:%s" % (len(failed), len(CASES), "、".join(failed)))
sys.exit(1)
print("✅ 全部 %d 个用例通过" % len(CASES))
+177
View File
@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""P5 测试:`foreach_account`(一轮把本机待发的号跑完)—— 临时库,不碰真实数据。"""
import io
import os
import shutil
import sys
import tempfile
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
TMP = tempfile.mkdtemp(prefix='ac_p5_')
os.environ['DATABASE_URL'] = 'sqlite:///' + TMP.replace(chr(92), '/') + '/ac.db'
os.environ['DATA_VIDEO_DIR'] = os.path.join(TMP, 'videos')
os.environ['DEPLOY_ENV'] = 'dev'
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _ROOT)
os.chdir(_ROOT)
from flask import Flask # noqa: E402
from core import db_config, models # noqa: E402
from core import video_plan as vp # noqa: E402
from tasks.generic import task as gt # noqa: E402
DATE = '2026-09-12'
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
app = Flask("t")
uri = db_config.build_db_uri()
app.config["SQLALCHEMY_DATABASE_URI"] = uri
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
models.db.init_app(app)
with app.app_context():
models.db.create_all()
vp.init_app(app)
def seed():
"""小王 2 条、小李 1 条、小张 1 条(未来)。"""
with app.app_context():
for i, (nm, d, s) in enumerate([('小王', DATE, 1), ('小王', DATE, 2),
('小李', DATE, 1), ('小张', '2027-01-01', 1)]):
models.db.session.add(models.VideoPlan(
id='p%d' % i, nickname=nm, phone='', device_name='', douyin_id='',
serial='', release_date=d, seq=s, seq_auto=False, title='t' + str(s),
body='', video_file='x', video_name='x', status=vp.ST_READY,
created_at=vp._now(), updated_at=vp._now()))
models.db.session.commit()
def status_of():
with app.app_context():
out = {}
for r in models.VideoPlan.query.all():
out.setdefault(r.nickname, []).append(r.status)
return {k: sorted(v) for k, v in out.items()}
print('=== 1. 一轮把本机待发的号跑完(模拟"推+发成功")===')
seed()
w = gt.GenericStepsWorker("FAKE01", {}, ctx={"job_id": "t1", "device_name": "A01"})
order = []
def fake_exec_ok(d, steps, depth=0):
"""模拟一段正常的循环体:把当前 target_name 的下一条待发计划标成 done。"""
name = w._vars.get('target_name')
order.append(name)
with app.app_context():
row = (models.VideoPlan.query.filter_by(nickname=name, status=vp.ST_READY)
.order_by(models.VideoPlan.seq).first())
if row:
row.status = vp.ST_DONE
models.db.session.commit()
w._exec_steps = fake_exec_ok
w._exec_foreach_account(None, {"children": [{"type": "x"}],
"date_source": "fixed", "fixed_date": DATE})
# 同日期同编号时按名字**码点**定序:李 U+674E < 王 U+738B → 小李先跑
check('两个号都跑过(小李 1 条 → 小王 2 条)', order, ['小李', '小王', '小王'])
check('小王两条都发了', status_of()['小王'], ['done', 'done'])
check('小李发了', status_of()['小李'], ['done'])
check('未来的小张没动', status_of()['小张'], ['ready'])
check('跑完把 target_name 清掉了', w._vars.get('target_name'), None)
print('=== 2. 本机没登这个号 → 不卡死,直接换下一个 ===')
with app.app_context():
models.VideoPlan.query.delete()
models.db.session.commit()
seed()
w2 = gt.GenericStepsWorker("FAKE02", {}, ctx={"job_id": "t2"})
seen = []
def fake_exec_only_xiaowang(d, steps, depth=0):
"""只有小王的号在这台机器上:切到别人时什么都不做(模拟切号失败/守卫挡下)。"""
name = w2._vars.get('target_name')
seen.append(name)
if name != '小王':
return
with app.app_context():
row = (models.VideoPlan.query.filter_by(nickname=name, status=vp.ST_READY)
.order_by(models.VideoPlan.seq).first())
if row:
row.status = vp.ST_DONE
models.db.session.commit()
w2._exec_steps = fake_exec_only_xiaowang
w2._exec_foreach_account(None, {"children": [{"type": "x"}],
"date_source": "fixed", "fixed_date": DATE})
check('小王发完(2 条)', status_of()['小王'], ['done', 'done'])
check('小李只被试了 1 次就跳过(没空转)', seen.count('小李'), 1)
check('试过的名字不回头', seen, ['小李', '小王', '小王'])
check('小李还是待发', status_of()['小李'], ['ready'])
print('=== 3. 下一轮会接着发小李(上一轮没发完的还在队列里)===')
w3 = gt.GenericStepsWorker("FAKE03", {}, ctx={"job_id": "t3"})
def fake_exec_all(d, steps, depth=0):
name = w3._vars.get('target_name')
with app.app_context():
row = (models.VideoPlan.query.filter_by(nickname=name, status=vp.ST_READY)
.order_by(models.VideoPlan.seq).first())
if row:
row.status = vp.ST_DONE
models.db.session.commit()
w3._exec_steps = fake_exec_all
w3._exec_foreach_account(None, {"children": [{"type": "x"}],
"date_source": "fixed", "fixed_date": DATE})
check('小李补上了', status_of()['小李'], ['done'])
print('=== 4. 没有待发的号 → 干净收工 ===')
w4 = gt.GenericStepsWorker("FAKE04", {}, ctx={"job_id": "t4"})
w4._exec_steps = lambda d, s, depth=0: None
w4._exec_foreach_account(None, {"children": [{"type": "x"}],
"date_source": "fixed", "fixed_date": DATE})
check('一条都没跑(不会死循环)', w4._vars.get('target_name'), None)
print('=== 5. max_accounts 上限 ===')
with app.app_context():
models.VideoPlan.query.delete()
models.db.session.commit()
seed()
w5 = gt.GenericStepsWorker("FAKE05", {}, ctx={"job_id": "t5"})
ran = []
w5._exec_steps = lambda d, s, depth=0: ran.append(w5._vars.get('target_name'))
w5._exec_foreach_account(None, {"children": [{"type": "x"}], "max_accounts": 1,
"date_source": "fixed", "fixed_date": DATE})
check('只跑 1 个号', ran, ['小李'])
print('=== 6. 子步骤为空 → 不报错、直接结束 ===')
w6 = gt.GenericStepsWorker("FAKE06", {})
w6._exec_foreach_account(None, {"children": []})
check('空 children 直接返回', w6._vars.get('target_name'), None)
print()
shutil.rmtree(TMP, ignore_errors=True)
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过(临时库已清理)')
+224
View File
@@ -0,0 +1,224 @@
# -*- coding: utf-8 -*-
"""P2 测试:读名字 / 按名字取计划 / 推送前核对(**临时库 + 假设备**,不碰真实数据、不连真机)。"""
import io
import os
import shutil
import sys
import tempfile
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
TMP = tempfile.mkdtemp(prefix='ac_p2_')
os.environ['DATABASE_URL'] = 'sqlite:///' + os.path.join(TMP, 'ac.db').replace('\\', '/')
os.environ['DATA_VIDEO_DIR'] = os.path.join(TMP, 'videos')
os.environ['DEPLOY_ENV'] = 'dev'
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _ROOT)
os.chdir(_ROOT)
from flask import Flask # noqa: E402
from core import db_config, models # noqa: E402
from core import video_plan as vp # noqa: E402
from tasks.generic import task as gt # noqa: E402
from tasks.generic import publish_flow as flow # noqa: E402
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
def ok(label, cond, detail=''):
if not cond:
fails.append(label)
print((' ✅ ' if cond else ' ❌ ') + label + ('' if cond else ' ' + str(detail)))
class StubEl:
"""假元素:u2 的 XPathSelector / UiObject 各用到的成员都给上。"""
def __init__(self, text):
self._t = text
@property
def exists(self):
return self._t is not None
def wait(self, timeout=0):
return self._t is not None
def get_text(self, timeout=None):
return self._t
class StubD:
def __init__(self, text):
self.text = text
def xpath(self, v):
return StubEl(self.text)
app = Flask("t")
uri = db_config.build_db_uri()
app.config["SQLALCHEMY_DATABASE_URI"] = uri
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
models.db.init_app(app)
with app.app_context():
models.db.create_all()
vp.init_app(app)
print('=== 1. clean_account_name(纯函数)===')
check('只有名字', vp.clean_account_name('小王'), '小王')
check('多行:取第一行', vp.clean_account_name('小王\n编辑资料\n抖音号:35377983067'), '小王')
check('首行是标签 → 往下找', vp.clean_account_name('编辑资料\n小王'), '小王')
check('抖音号行跳过', vp.clean_account_name('抖音号:35377983067\n小王'), '小王')
check('统计行跳过', vp.clean_account_name('关注\n粉丝\n小王'), '小王')
check('名字里的双空格保留', vp.clean_account_name('有牛奶面包 你吃吗?'), '有牛奶面包 你吃吗?')
check('名字带表情保留', vp.clean_account_name('🐟小鱼'), '🐟小鱼')
check('「关注我领福利」不当标签', vp.clean_account_name('关注我领福利'), '关注我领福利')
check('读到空 → ""', vp.clean_account_name('编辑资料\n抖音号:123'), '')
check('None → ""', vp.clean_account_name(None), '')
check('首尾空白去掉', vp.clean_account_name(' 小王 '), '小王')
print('=== 2. 临时库里造两条计划(小王 / 小李)===')
with app.app_context():
def mk(name, date, seq, status=vp.ST_READY, title="标题"):
row = models.VideoPlan(id=name + str(seq) + date[-2:], nickname=name, phone="",
device_name="", douyin_id="", serial="", release_date=date,
seq=seq, seq_auto=False, title=title, body="",
video_file=f"2026-10/x{seq}.mp4", video_name="x.mp4",
status=status, created_at=vp._now(), updated_at=vp._now())
models.db.session.add(row)
mk('小王', '2026-09-12', 1)
mk('小王', '2026-09-13', 2) # 同一个号,后天那条
mk('小李', '2026-09-12', 1)
mk('小张', '2026-09-20', 1) # 未来:不该被取到
mk('小赵', '2026-09-10', 1, status=vp.ST_DONE) # 已发:不该被取到
models.db.session.commit()
print('=== 3. 按名字取计划 ===')
check('小王 在 09-12 有一条', [p['seq'] for p in vp.take_for_name(['小王'], '2026-09-12')], [1])
check('取 09-13 时,过期的那条先发', [p['seq'] for p in vp.take_for_name(['小王'], '2026-09-13')], [1])
check('小李 只认自己的', [p['nickname'] for p in vp.take_for_name(['小李'], '2026-09-12')],
['小李'])
check('没有的名字取不到', vp.take_for_name(['不存在'], '2026-09-12'), [])
check('名字为空不取(绝不猜)', vp.take_for_name([], '2026-09-12'), [])
check('未来的不发', vp.take_for_name(['小张'], '2026-09-12'), [])
check('已发的(done)不再入队', vp.take_for_name(['小赵'], '2026-09-12'), [])
check('peek 只看不占位', vp.peek_for_name(['小王'], '2026-09-12')['seq'], 1)
check('peek 不存在的名字 → None', vp.peek_for_name(['不存在'], '2026-09-12'), None)
print('=== 4. pending_names / pending_count(循环块的数据源)===')
# 同日期同编号时按名字的**码点**定序(只要确定就行,不追求拼音序):李 U+674E < 王 U+738B
check('有待发内容的名字(按最早日期,同日期按名字定序)',
[(x['name'], x['release_date'], x['seq'])
for x in vp.pending_names('2026-09-13')],
[('小李', '2026-09-12', 1), ('小王', '2026-09-12', 1)])
check('只看指定的名字', [x['name'] for x in vp.pending_names('2026-09-13', names=['小王'])],
['小王'])
check('小王 待发 2 条', vp.pending_count('小王', '2026-09-13'), 2)
check('小李 待发 1 条', vp.pending_count('小李', '2026-09-13'), 1)
check('已完成的名字不在列表里', [x['name'] for x in vp.pending_names('2026-09-12', names=['小赵'])],
[])
check('未来日期还没到 → 今天不列', vp.pending_count('小张', '2026-09-12'), 0)
print('=== 5. 读名字步骤(假设备)===')
ctx = {"job_id": "t1", "job_name": "测试"}
w = gt.GenericStepsWorker("FAKE01", {}, ctx=ctx)
el_raw = '小王\n编辑资料\n抖音号:35377983067'
r = w._exec_read_account_name(StubD(el_raw), {"selector_type": "xpath",
"selector_value": "//x", "timeout": 1})
check('读到名字 → True', r, True)
check('_vars.account_name', w._vars.get('account_name'), '小王')
check('_vars.name_selector 记下了', w._vars.get('name_selector'), ('xpath', '//x'))
w2 = gt.GenericStepsWorker("FAKE02", {}, ctx=ctx)
r2 = w2._exec_read_account_name(StubD('编辑资料\n抖音号:123'),
{"selector_type": "xpath", "selector_value": "//x", "timeout": 1})
check('读不到(on_missing=fail)→ False', r2, False)
check('读不到时不写 account_name', w2._vars.get('account_name'), None)
w3 = gt.GenericStepsWorker("FAKE03", {}, ctx=ctx)
r3 = w3._exec_read_account_name(StubD('编辑资料'), {"selector_type": "xpath",
"selector_value": "//x",
"on_missing": "skip"})
check('on_missing=skip → None(不失败)', r3, None)
w4 = gt.GenericStepsWorker("FAKE04", {}, ctx=ctx)
r4 = w4._exec_read_account_name(StubD('小王'), {"selector_type": "xpath",
"selector_value": ""})
check('选择器为空 → False(并提示去抓元素)', r4, False)
print('=== 6. _release_scope:这次发的是哪个号 ===')
check('默认按名字(读到谁就是谁)', w._release_scope({}), ('name', ['小王'], '名字『小王』'))
check('没有名字 → 空值(跳过,不猜)', w2._release_scope({}),
('name', [], '没读到本机账号名字(前面要有「读取本机账号名字」,'
'或由循环块设目标名字)→ 跳过'))
check('account=phone 走老路径', w._release_scope({'account': 'phone',
'account_phone': '13300000000'}),
('phone', ['13300000000'], '指定手机号 13300000000'))
check('cycle 设的目标名字优先', (w._vars.__setitem__('target_name', '小李'),
w._release_scope({}))[1], ('name', ['小李'], '名字『小李』'))
w._vars.pop('target_name')
print('=== 7. 条件判断候选值 ===')
txt, note = w._resolve_cmp_value({'cmp_source': 'account_target'}, '')
check('account_target → 候选=名字', txt, '小王')
ok('说明里写了名字', '小王' in note, note)
txt2, _ = w2._resolve_cmp_value({'cmp_source': 'account_target'}, '')
check('读不到名字 → 候选为空(永远不命中 → 不发)', txt2, '')
txt3, _ = w2._resolve_cmp_value({'cmp_source': 'account_target', 'cmp_value': '手填的'}, '手填的')
check('读不到名字时手填值仍然生效', txt3, '手填的')
print('=== 8. 推送前核对(假设备,push_one 打桩)===')
calls = []
orig = flow.push_one
def fake_push(serial, plan, opts, log=None):
calls.append((serial, plan['id']))
return True, "ok"
flow.push_one = fake_push
try:
# 8a 屏幕上不是我们要发的人 → 不推
w._vars['account_name'] = '小王'
w._vars['name_selector'] = ('xpath', '//x')
bad = w._exec_push_release(StubD('小李'), {'account': 'name',
'date_source': 'fixed',
'fixed_date': '2026-09-12'})
check('核对不过 → 不推(返回 None)', bad, None)
check('push_one 一次都没调', calls, [])
# 8b 屏幕上就是本人 → 推
good = w._exec_push_release(StubD('小王'), {'account': 'name',
'date_source': 'fixed',
'fixed_date': '2026-09-12'})
check('核对通过 → 推成功', good, True)
check('push_one 被调了 1 次', len(calls), 1)
check('推的是小王那条', calls[0][1], '小王112')
# 8c 读不到名字 → 不推
calls.clear()
w2._vars['name_selector'] = ('xpath', '//x')
none_ = w2._exec_push_release(StubD('编辑资料'), {'date_source': 'fixed',
'fixed_date': '2026-09-12'})
check('读不到名字 → 不推', none_, None)
check('push_one 没被调', calls, [])
finally:
flow.push_one = orig
print()
shutil.rmtree(TMP, ignore_errors=True)
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过(临时库已清理)')
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""P1 纯函数测试:`名字_日期_编号` 解析(不碰 DB、不碰用户数据)。"""
import io
import os
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _ROOT)
os.chdir(_ROOT)
from core import video_plan as vp # noqa: E402
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
print('=== parse_video_name:合法 ===')
check('名字_日期_编号', vp.parse_video_name('小王_20260912_1.mp4')[0],
{"name": "小王", "date": "2026-09-12", "seq": 1, "seq_given": True})
check('名字_日期(无编号)', vp.parse_video_name('建材王总_20260913.mp4')[0],
{"name": "建材王总", "date": "2026-09-13", "seq": None, "seq_given": False})
check('名字里带数字', vp.parse_video_name('翻斗花园我最强2号_20260913.mp4')[0]['name'], '翻斗花园我最强2号')
check('名字里带空格/表情', vp.parse_video_name('有牛奶面包 你吃吗?_20260912_2.mp4')[0]['name'],
'有牛奶面包 你吃吗?')
check('名字里带表情', vp.parse_video_name('🐟小鱼_20260912.mp4')[0]['name'], '🐟小鱼')
check('大写扩展名', vp.parse_video_name('小王_20260912.MP4')[0]['date'], '2026-09-12')
check('编号 3 位', vp.parse_video_name('小王_20260912_999.mp4')[0]['seq'], 999)
print('=== parse_video_name:拒收 ===')
def err_of(fn):
return fn[1]
check('名字带下划线', err_of(vp.parse_video_name('小_王_20260912.mp4')),
'名字里不能有下划线:`小_王_20260912` 里日期前面有 2 段 ——应为 名字_日期_编号')
check('名字是纯数字', err_of(vp.parse_video_name('12345678_20260912.mp4')),
'名字不能是纯数字(12345678 会被当成日期/编号)—— 请填抖音上的昵称')
check('结尾不是日期', err_of(vp.parse_video_name('小王_20260912_1234.mp4')),
'文件名格式不对:`小王_20260912_1234` 的结尾既不是日期(8 位数字,如 20260912),'
'也不是「日期_编号」—— 应为 名字_日期_编号')
check('老格式(手机号在前)', err_of(vp.parse_video_name('13382508423_20261011_01.mp4')),
'这一段(13382508423)是手机号 —— 文件名第一段要填**抖音上的名字**,'
'格式是 名字_日期_编号(现在不认手机号了)')
check('非法日期', err_of(vp.parse_video_name('小王_20260230.mp4')),
'日期 20260230 不是合法日期(应为 20260912 这种 8 位)')
check('只有一段', err_of(vp.parse_video_name('小王.mp4')),
'文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)')
check('不是视频', err_of(vp.parse_video_name('小王_20260912.txt')),
'不是视频文件(支持 mp4/mov/mkv/avi/3gp/flv/wmv/m4v/ts/webm)')
check('空文件名', err_of(vp.parse_video_name('')), '文件名为空')
check('多段(名字含下划线还带日期)', err_of(vp.parse_video_name('小_王_总_20260912.mp4')),
'名字里不能有下划线:`小_王_总_20260912` 里日期前面有 3 段 ——应为 名字_日期_编号')
print('=== parse_title_line:合法 ===')
check('标题 || 正文_名字_日期_编号', vp.parse_title_line('中秋祝福 || 祝大家中秋快乐_小王_20260912_1')[0],
{"title": "中秋祝福", "body": "祝大家中秋快乐", "name": "小王",
"date": "2026-09-12", "seq": 1, "seq_given": True})
check('只有标题', vp.parse_title_line('中秋祝福_小王_20260912')[0],
{"title": "中秋祝福", "body": "", "name": "小王",
"date": "2026-09-12", "seq": None, "seq_given": False})
check('只有正文(|| 在最前)', vp.parse_title_line(' || 只有正文_小王_20260912')[0]['body'], '只有正文')
check('标题里带下划线', vp.parse_title_line('名字带_下划线也行_小王_20260912')[0]['title'], '名字带_下划线也行')
check('#话题开头的标题', vp.parse_title_line('#中秋快乐_小王_20260929')[0]['title'], '#中秋快乐')
check('正文里再有 || 原样保留', vp.parse_title_line('标题 || 正文 || 又一处_小王_20260912')[0]['body'],
'正文 || 又一处')
check('标题是纯数字', vp.parse_title_line('2026_小王_20260912_1')[0]['title'], '2026')
print('=== parse_title_line:拒收 ===')
check('只有名字/日期(没文字)', err_of(vp.parse_title_line('小王_20260912')),
'缺标题/正文内容(只有名字/日期/编号,没有文字)')
check('名字/日期/编号(没文字)', err_of(vp.parse_title_line('小王_20260912_1')),
'缺标题/正文内容(只有名字/日期/编号,没有文字)')
check('老写法(名字在前)', err_of(vp.parse_title_line('19286456013_20260912_1_标题')),
"格式不对,应为「标题 || 正文_名字_日期_编号」(编号可省略;日期是 8 位数字,如 20260912)")
check('名字是手机号', err_of(vp.parse_title_line('标题_13382508423_20260912')),
'这一段(13382508423)是手机号 —— 文件名第一段要填**抖音上的名字**,'
'格式是 名字_日期_编号(现在不认手机号了)')
check('标题里带下划线(名字取紧挨日期那段)',
vp.parse_title_line('标题_小_王_20260912')[0],
{"title": "标题_小", "body": "", "name": "王", "date": "2026-09-12",
"seq": None, "seq_given": False})
check('空行', err_of(vp.parse_title_line(' ')), '空行')
print('=== parse_titles_text:注释与多行 ===')
rows, errors = vp.parse_titles_text(
'标题一_小王_20260912\n# 这是注释\n\n#中秋快乐_小王_20260913\n'
'#真注释_没有日期\n标题没有日期')
check('收下 2 条标题', [r['title'] for r in rows], ['标题一', '#中秋快乐'])
check('# 认不出名字/日期 → 当注释忽略', [(e['line'], e['raw']) for e in errors], [(6, '标题没有日期')])
print('=== pair_slots:按名字配对 ===')
videos = [dict(vp.parse_video_name('小王_20260912_1.mp4')[0], ref='v1'),
dict(vp.parse_video_name('小王_20260912_2.mp4')[0], ref='v2'),
dict(vp.parse_video_name('小李_20260912.mp4')[0], ref='v3')]
titles = [dict(vp.parse_title_line('标题A_小王_20260912')[0], ref='t1'),
dict(vp.parse_title_line('标题B_小王_20260912')[0], ref='t2'),
dict(vp.parse_title_line('标题C_小李_20260912')[0], ref='t3')]
pairs, rejected = vp.pair_slots(videos, titles)
check('配成 3 对', len(pairs), 3)
check('没有拒收', rejected, [])
check('按 (日期,名字,编号) 排序', [(p['date'], p['name'], p['seq']) for p in pairs],
[('2026-09-12', '小李', 1), ('2026-09-12', '小王', 1), ('2026-09-12', '小王', 2)])
check('每对都配上标题', sorted(p['title']['title'] for p in pairs), ['标题A', '标题B', '标题C'])
check('配对结果里的键是 name', all('name' in p and 'phone' not in p for p in pairs), True)
print()
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过')
+146
View File
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""P6 测试:按名字找设备推送(`_push_by_name`)—— 临时库 + 假探测/假推送,不碰真机。"""
import io
import os
import shutil
import sys
import tempfile
import time
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
TMP = tempfile.mkdtemp(prefix='ac_p6_')
os.environ['DATABASE_URL'] = 'sqlite:///' + TMP.replace(chr(92), '/') + '/ac.db'
os.environ['DATA_VIDEO_DIR'] = os.path.join(TMP, 'videos')
os.environ['DEPLOY_ENV'] = 'dev'
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _ROOT)
os.chdir(_ROOT)
from flask import Flask # noqa: E402
from core import db_config, models, device_pool # noqa: E402
from core import video_plan as vp # noqa: E402
from core import account_probe # noqa: E402
from tasks.generic import publish_flow as flow # noqa: E402
from web import video_plan_api as vapi # noqa: E402
DATE = '2026-09-12'
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
app = Flask("t")
uri = db_config.build_db_uri()
app.config["SQLALCHEMY_DATABASE_URI"] = uri
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
models.db.init_app(app)
with app.app_context():
models.db.create_all()
vp.init_app(app)
device_pool.init_app(app)
with app.app_context():
models.db.session.add(models.Device(serial='A:5555', name='A01', enabled=True,
created_at=vp._now()))
models.db.session.add(models.Device(serial='B:5555', name='A02', enabled=True,
created_at=vp._now()))
for i, (nm, s) in enumerate([('小王', 1), ('小王', 2), ('小李', 1)]):
models.db.session.add(models.VideoPlan(
id='p%d' % i, nickname=nm, phone='', device_name='', douyin_id='', serial='',
release_date=DATE, seq=s, seq_auto=False, title='t', body='',
video_file='x', video_name='x', status=vp.ST_READY,
created_at=vp._now(), updated_at=vp._now()))
models.db.session.commit()
pushed = []
lock = []
def fake_push(serial, plan, opts=None, log=None):
pushed.append((serial, plan['nickname'], plan['seq'], (opts or {}).get('device_name')))
return True, "ok"
print('=== 1. 名字 → 设备(两台设备各登一个)===')
probe = {'A:5555': {'serial': 'A:5555', 'name': '小王', 'error': ''},
'B:5555': {'serial': 'B:5555', 'name': '小李', 'error': ''}}
orig_probe, orig_push = account_probe.probe_online, flow.push_one
account_probe.probe_online = lambda serials=None, skip_busy=True, workers=4: probe
flow.push_one = fake_push
try:
with app.app_context():
rows = vp.pushable_plans(DATE)
matched, unmatched = vapi._push_by_name(rows)
deadline = time.time() + 3
while len(pushed) < 3 and time.time() < deadline:
time.sleep(0.05)
check('两个名字都找到设备', sorted((m['name'], m['count']) for m in matched),
[('小李', 1), ('小王', 2)])
check('小王的设备是 A01', [m['device_name'] for m in matched if m['name'] == '小王'], ['A01'])
check('没有 unmatched', unmatched, [])
check('推了小王的 2 条 + 小李的 1 条', len(pushed), 3)
check('推给 A 的是小王的两条(按编号)',
sorted((s, q) for (ser, n, q, dn) in pushed if ser == 'A:5555'), [(1, 1), (1, 2)])
check('推给 B 的是小李', [(n, q) for (ser, n, q, dn) in pushed if ser == 'B:5555'],
[('小李', 1)])
check('顺手把设备名字传给了 push_one(写 posted_device_name 用)',
{dn for (ser, n, q, dn) in pushed}, {'A01', 'A02'})
with app.app_context():
cached = [(d.serial, d.cur_account_name)
for d in models.Device.query.order_by(models.Device.serial)]
check('探测结果缓存进了 device 表', cached,
[('A:5555', '小王'), ('B:5555', '小李')])
print('=== 2. 没人登着某个名字 → unmatched 里逐条说明 ===')
pushed.clear()
probe2 = {'A:5555': {'serial': 'A:5555', 'name': '小王', 'error': ''},
'B:5555': {'serial': 'B:5555', 'name': '别的号', 'error': ''}}
account_probe.probe_online = lambda serials=None, skip_busy=True, workers=4: probe2
with app.app_context():
rows = vp.pushable_plans(DATE)
matched2, unmatched2 = vapi._push_by_name(rows)
deadline = time.time() + 3
while len(pushed) < 2 and time.time() < deadline:
time.sleep(0.05)
check('只推了小王那 2 条', len(pushed), 2)
check('小李进了 unmatched', [(u['name'], u['count']) for u in unmatched2], [('小李', 1)])
check('原因说清了「先切号」', ('没有在线设备登着' in unmatched2[0]['reason']
and '小李' in unmatched2[0]['reason']
and '先到设备上切到它' in unmatched2[0]['reason']), True)
check('原因里带上当前探测到的号(排查用,别把整个 dict 打出来)',
unmatched2[0]['reason'].endswith('(当前探测到:A:5555(小王)、B:5555(别的号))'), True)
if 'dict' in unmatched2[0]['reason'] or '{' in unmatched2[0]['reason']:
print(' 原因里混进了原始 dict:', unmatched2[0]['reason'])
print('=== 3. 探测失败(比如选择器没配)→ 也算没设备登着,不炸 ===')
pushed.clear()
account_probe.probe_online = lambda serials=None, skip_busy=True, workers=4: {
'A:5555': {'serial': 'A:5555', 'name': '', 'error': '还没抓昵称控件'},
'B:5555': {'serial': 'B:5555', 'name': '', 'error': '连不上设备'}}
with app.app_context():
rows = vp.pushable_plans(DATE)
matched3, unmatched3 = vapi._push_by_name(rows)
check('一条都没推', pushed, [])
check('两个名字都 unmatched', sorted(u['name'] for u in unmatched3), ['小李', '小王'])
with app.app_context():
keep = models.Device.query.get('A:5555').cur_account_name
check('失败的那台不清空旧缓存', keep, '小王')
finally:
account_probe.probe_online, flow.push_one = orig_probe, orig_push
print()
shutil.rmtree(TMP, ignore_errors=True)
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过(临时库已清理)')
+208
View File
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""P4 测试:`switch_account`(按名字找控件点击 + 回读校验)—— 假设备,不碰真机。
不连 adb、不连真机:`d` 是个模拟对象,只实现 u2 里这一步用到的那几个成员。
"""
import io
import os
import sys
import time
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
os.environ['DEPLOY_ENV'] = 'dev'
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _ROOT)
os.chdir(_ROOT)
from tasks.generic import task as gt # noqa: E402
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
class Exists:
"""u2 的 `exists` 语义:**既可当属性判真假、也可当函数带超时调用**。
真实 u2 里 `d.xpath(...).exists` 是纯 bool 属性,而 `d(text=...).exists` 是
可调用的 Exists 对象(`bool()` 即时判断、`exists(timeout=t)` 带等待)。
两处写法都要能被这一步的代码用,所以桩里做个两者通吃的。
"""
def __init__(self, flag):
self.flag = flag
def __call__(self, timeout=0):
return self.flag
def __bool__(self):
return self.flag
class El:
def __init__(self, text=None, exists=True, on_click=None):
self._t = text
self._on = on_click
self._e = Exists(exists)
@property
def exists(self):
return self._e
def wait(self, timeout=0):
return self._e.flag
def get_text(self, timeout=None):
return self._t
def click(self):
if self._on:
self._on()
class StubD:
"""模拟抖音:能读昵称、能进账号列表、能按名字点号。"""
def __init__(self, cur='小李', accounts=('小王', '小李'), login=False):
self.cur = cur
self.accounts = list(accounts)
self.login = login
self.clicked = []
self.switch_ok = True # False = 点了但没切过去(模拟没生效)
self.switch_open = True # False = 进不去账号列表
def _click_name(self, name):
self.clicked.append(name)
if self.switch_ok:
self.cur = name
LOGIN_HINTS = ('验证码', '其他方式登录', '手机号登录', '一键登录', '密码登录')
def xpath(self, v):
if v == '//name':
# 登录页上**根本没有昵称这个控件**(元素不存在)—— 这才是真实情况,
# 所以"读名字"会读到空串(而不是读到一堆登录页的文字)
return El(None, exists=False) if self.login else El(self.cur)
if v == '//switch':
return El('', exists=(self.switch_open and not self.login))
return El('', exists=False)
def __call__(self, **kw):
if 'descriptionContains' in kw:
return El('', exists=(kw['descriptionContains'] == '我'))
if 'textContains' in kw:
nm = kw['textContains']
if self.login and nm in self.LOGIN_HINTS:
return El(nm)
if nm in self.accounts:
return El(nm, on_click=lambda n=nm: self._click_name(n))
return El('', exists=False)
if 'text' in kw:
nm = kw['text']
if nm in self.accounts:
return El(nm, on_click=lambda n=nm: self._click_name(n))
return El('', exists=False)
return El('', exists=False)
PARAMS = {'target': 'var:target_name', 'switch_selector_type': 'xpath',
'switch_selector_value': '//switch', 'name_selector_type': 'xpath',
'name_selector_value': '//name', 'timeout': 1, 'max_attempts': 2}
notes = []
orig_notify = gt.notifier.notify
gt.notifier.notify = lambda ev, **kw: notes.append((ev, kw.get('want') or ''))
try:
print('=== 1. 已经是目标 → 直接成功,不点任何东西 ===')
w = gt.GenericStepsWorker("F1", {})
w._vars['target_name'] = '小李'
d = StubD(cur='小李')
check('返回 True', w._exec_switch_account(d, PARAMS), True)
check('一个号都没点', d.clicked, [])
print('=== 2. 切到另一个号(在列表里按名字点)===')
w = gt.GenericStepsWorker("F2", {})
w._vars['target_name'] = '小王'
d = StubD(cur='小李')
check('返回 True', w._exec_switch_account(d, PARAMS), True)
check('点是按名字点的', d.clicked, ['小王'])
check('当前号变了', d.cur, '小王')
check('顺手把运行期变量同步成新名字', w._vars.get('account_name'), '小王')
print('=== 3. 列表里没有这个名字(本机没登)→ 立刻 False、不重试 ===')
notes.clear()
w = gt.GenericStepsWorker("F3", {})
w._vars['target_name'] = '不存在的号'
d = StubD(cur='小李')
check('返回 False', w._exec_switch_account(d, PARAMS), False)
check('只找了一次(没重试)', d.clicked, [])
check('发了切号失败通知', [n[0] for n in notes], ['task.account.switch_failed'])
check('通知里带上想切的目标', notes[0][1], '不存在的号')
print('=== 4. 点了但没切过去 → 按 max_attempts 重试后放弃 + 通知 ===')
notes.clear()
w = gt.GenericStepsWorker("F4", {})
w._vars['target_name'] = '小王'
d = StubD(cur='小李')
d.switch_ok = False # 抖音没反应
check('返回 False', w._exec_switch_account(d, PARAMS), False)
check('试了 2 次(max_attempts=2)', d.clicked, ['小王', '小王'])
check('发了切号失败通知', [n[0] for n in notes], ['task.account.switch_failed'])
print('=== 5. 卡在登录/验证码页 → 通知 + 停本设备 ===')
notes.clear()
w = gt.GenericStepsWorker("F5", {})
w._vars['target_name'] = '小王'
d = StubD(cur='小李', login=True)
check('返回 False', w._exec_switch_account(d, PARAMS), False)
check('发了 need_login 通知', [n[0] for n in notes], ['task.account.need_login'])
check('本设备已停止', w.stopped(), True)
print('=== 6. on_login_page=continue → 不停设备(只记一句)===')
notes.clear()
w = gt.GenericStepsWorker("F6", {})
w._vars['target_name'] = '小王'
d = StubD(cur='小李', login=True)
p = dict(PARAMS, on_login_page='continue')
check('返回 False', w._exec_switch_account(d, p), False)
check('还是发了 need_login 通知', [n[0] for n in notes], ['task.account.need_login'])
check('设备没被停', w.stopped(), False)
print('=== 7. 进账号列表的选择器没配 → 明确报错、不乱点 ===')
notes.clear()
w = gt.GenericStepsWorker("F7", {})
w._vars['target_name'] = '小王'
d = StubD(cur='小李')
p = dict(PARAMS, switch_selector_value='')
check('返回 False', w._exec_switch_account(d, p), False)
check('没点任何东西', d.clicked, [])
check('没瞎发通知(这是配置问题,不是切号失败)', notes, [])
print('=== 8. 没给目标名字 → 跳过 ===')
w = gt.GenericStepsWorker("F8", {})
d = StubD(cur='小李')
check('返回 False', w._exec_switch_account(d, PARAMS), False)
check('没点任何东西', d.clicked, [])
print('=== 9. verify=False → 点了就算过(不回读)===')
w = gt.GenericStepsWorker("F9", {})
w._vars['target_name'] = '小王'
d = StubD(cur='小李')
d.switch_ok = False
check('返回 True', w._exec_switch_account(d, dict(PARAMS, verify=False)), True)
finally:
gt.notifier.notify = orig_notify
print()
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过(没连任何真机)')
+146
View File
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""P1 集成测试:按名字上传/配标题走通(**临时 SQLite + 临时视频目录**,不碰真实数据)。"""
import io
import os
import shutil
import sys
import tempfile
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
TMP = tempfile.mkdtemp(prefix='ac_p1_')
os.environ['DATABASE_URL'] = 'sqlite:///' + os.path.join(TMP, 'ac.db').replace('\\', '/')
os.environ['DATA_VIDEO_DIR'] = os.path.join(TMP, 'videos')
os.environ['DEPLOY_ENV'] = 'dev'
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, _ROOT)
os.chdir(_ROOT)
from flask import Flask # noqa: E402
from sqlalchemy import inspect # noqa: E402
from core import db_config, models # noqa: E402
from core import video_plan as vp # noqa: E402
fails = []
def check(label, got, want):
ok = got == want
if not ok:
fails.append(label)
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
def ok(label, cond, detail=''):
if not cond:
fails.append(label)
print((' ✅ ' if cond else ' ❌ ') + label + ('' if cond else ' ' + str(detail)))
class FakeFile:
"""够用的 FileStorage 替身:只用到 .filename / .stream。"""
def __init__(self, filename, data):
self.filename = filename
self.stream = io.BytesIO(data)
app = Flask("t")
uri = db_config.build_db_uri()
print('测试库:', uri)
app.config["SQLALCHEMY_DATABASE_URI"] = uri
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
models.db.init_app(app)
with app.app_context():
models.db.create_all()
models._ensure_plain_indexes() # 生产启动路径里的那一步(老库补索引)
vp.init_app(app)
with app.app_context():
print('=== 0. 前提:台账一张空表也不依赖 ===')
check('device_account 里 0 行(上传完全不查台账)', models.DeviceAccount.query.count(), 0)
check('video_plan 里 0 行', models.VideoPlan.query.count(), 0)
print('=== 1. 上传:名字_日期_编号 ===')
r = vp.upload_video(FakeFile('小王_20260912_1.mp4', b'A' * 500))
ok('上传成功', r.get('ok') is True, r)
p = r['plan']
check('昵称 = 文件名里的名字', p['nickname'], '小王')
check('手机号留空(不再写台账快照)', p['phone'], '')
check('设备号留空', p['device_name'], '')
check('发布日期', p['release_date'], '2026-09-12')
check('编号', p['seq'], 1)
check('编号不是自动的', p['seq_auto'], False)
check('状态 = 待标题', p['status'], 'pending')
print('=== 2. 幂等:同一份内容重传 ===')
r2 = vp.upload_video(FakeFile('小王_20260912_1.mp4', b'A' * 500))
check('跳过', r2.get('action'), 'skip')
print('=== 3. 同槽位换内容:先拒收、replace 才行 ===')
r3 = vp.upload_video(FakeFile('小王_20260912_1.mp4', b'B' * 600))
ok('拒收', r3.get('ok') is False and '已有视频' in (r3.get('error') or ''), r3)
r4 = vp.upload_video(FakeFile('小王_20260912_1.mp4', b'B' * 600), replace=True)
check('覆盖成功', r4.get('action'), 'replace')
check('还是 1 行', models.VideoPlan.query.count(), 1)
print('=== 4. 不带编号 → 自动排到空位 ===')
r5 = vp.upload_video(FakeFile('小王_20260912.mp4', b'C' * 700))
check('自动编号 = 2', r5['plan']['seq'], 2)
check('标记为自动', r5['plan']['seq_auto'], True)
print('=== 5. 老格式(手机号)被拒 + 说清怎么改 ===')
r6 = vp.upload_video(FakeFile('13382508423_20261011_01.mp4', b'D' * 700))
ok('拒收且提示是手机号', r6.get('ok') is False and '手机号' in (r6.get('error') or ''), r6)
check('库里还是 2 行', models.VideoPlan.query.count(), 2)
print('=== 6. 配标题(`标题 || 正文_名字_日期_编号`)===')
t1 = vp.import_titles('聚划算 || 今天全场五折,点进来看看_小王_20260912')
check('配到 1 条', t1['counts']['attached'], 1)
row1 = models.VideoPlan.query.filter_by(nickname='小王', release_date='2026-09-12',
seq=1).first()
check('标题落库', row1.title, '聚划算')
check('正文落库', row1.body, '今天全场五折,点进来看看')
check('状态自动切到待发布', row1.status, 'ready')
check('返回体里键是 name', t1['rows'][0]['name'], '小王')
t2 = vp.import_titles('第二个 || 正文二_小王_20260912_2')
check('按编号精确配对', t2['counts']['attached'], 1)
row2 = models.VideoPlan.query.filter_by(nickname='小王', release_date='2026-09-12',
seq=2).first()
check('第 2 条标题', row2.title, '第二个')
t3 = vp.import_titles('这条没有主_不存在的名字_20260912')
check('认不出名字 → 拒收', (t3['counts']['rejected'], t3['rows'][0]['error']),
(1, '该名字 2026-09-12 没有待配标题的视频(请先上传视频)'))
# 带编号重配同一条 → 认出来是"配过了",跳过(幂等)
t4 = vp.import_titles('聚划算 || 今天全场五折,点进来看看_小王_20260912_1')
check('带编号重配同一条 → 跳过', t4['counts']['skipped'], 1)
# 不带编号:它只会找"还没有标题的行",两行都配过 → 拒收(既有语义,文案说清了原因)
t5 = vp.import_titles('聚划算 || 今天全场五折,点进来看看_小王_20260912')
check('不带编号且两行都有标题 → 拒收', (t5['counts']['rejected'], t5['rows'][0]['error']),
(1, '该名字 2026-09-12 没有待配标题的视频(请先上传视频)'))
print('=== 7. 老命名空间不受影响:按名字查重/取槽位 ===')
check('taken_slots 按名字', vp.taken_slots('小王', '2026-09-12'), {1, 2})
check('别的名字是空的', vp.taken_slots('小李', '2026-09-12'), set())
print('=== 8. 索引补建 ===')
idx = {i['name'] for i in inspect(models.db.engine).get_indexes('video_plan')}
ok('ix_video_plan_name_date 已建', 'ix_video_plan_name_date' in idx, sorted(idx))
print('=== 9. 素材落盘 ===')
files = os.listdir(os.path.join(TMP, 'videos', '2026-10'))
check('落盘 2 个文件(覆盖后旧文件已删)', len(files), 2)
print()
shutil.rmtree(TMP, ignore_errors=True)
if fails:
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
sys.exit(1)
print('✅ 全部通过(临时库已清理)')