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:
@@ -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))
|
||||
@@ -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('✅ 全部通过(临时库已清理)')
|
||||
@@ -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('✅ 全部通过(临时库已清理)')
|
||||
@@ -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('✅ 全部通过')
|
||||
@@ -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('✅ 全部通过(临时库已清理)')
|
||||
@@ -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('✅ 全部通过(没连任何真机)')
|
||||
@@ -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('✅ 全部通过(临时库已清理)')
|
||||
Reference in New Issue
Block a user