Files
auto_control/scripts/tests/test_switch_account.py
T
butubb 1b65ffe089 feat(发布计划): 素材支持图片(图文)—— 上传视频/图片,发布流程共用一条 + 切号健壮性再修
用户实测反馈:"图片流程一样,现在也是点第一个" → 图片不做单独一套流程,只放开上传侧;
扩展名一路原样带到手机上(抖音按扩展名/MIME 判断视频还是图文,图片推成 .mp4 会变坏视频)。

· core/video_plan.py:VIDEO_EXTS + IMAGE_EXTS(jpg/jpeg/png/webp/heic) → MEDIA_EXTS;
  报错文案改"不是视频/图片文件"
· tasks/generic/publish_flow.push_video:ext 默认**从本地文件名取**(不再硬编码 .mp4);
  drop_old_copies 收到真实扩展名
· tasks/generic/task.py:删手机素材优先用推送时记下的 push_remote,其次按素材扩展名现算;
  切号:等面板开稳再点(刚出现就点会被入场动画吞掉)、点完面板还开着(点的就是当前号)
  就先关面板、一切以**回读**为准、回读窗口 12s→30s(换号会触发抖音整体重启,实测 77s 才回来);
  失败时日志附带"当时屏幕上是什么"(亮屏/前台包/屏上文字)—— "点了没反应"一眼可判
· 骨架:先 stop_app 冷启动(抖音可能停在发布页/别的视频页,那种页面上没有「我」页签,
  切号第一步就废);「校验账号」的选择器与「读名字」统一从 .env 取(漏了这一步会静默跳过
  整个发布流程 —— 用户实际遇到的"直接完成、没发布"就是这个)
· 界面:上传视频 → 上传视频/图片,accept 加 image/*,明细表"视频文件"→"素材文件",help 文案
· 文档:TASK_DEV §4.7 / API / DATA_MODEL / ARCHITECTURE / README 同步
· 测试:解析用例补图片(jpg/png/webp/大写 JPG 认,gif 不收)+ 改报错文案期望
2026-10-12 10:13:49 +08:00

212 lines
8.2 KiB
Python

# -*- 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
self.accounts = [] # 真机上点成功后**面板会关掉**(那几行消失)——
# 步骤就是靠这个判断"点成功了"的:面板还在 = 这一下被吞了,要再点一次
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)
# 面板一直没关(点一次吞一次)→ 每次尝试点两下、共 2 次尝试
check('试了 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('✅ 全部通过(没连任何真机)')