Files
auto_control/scripts/tests/test_switch_account.py
T
butubb beda0f3779 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(临时库 + 假设备,不碰真实数据、不连真机)
2026-10-12 09:02:40 +08:00

209 lines
7.9 KiB
Python
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.
# -*- 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('✅ 全部通过(没连任何真机)')