PyPtt 2.3.1__tar.gz → 2.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyptt-2.3.1 → pyptt-2.3.3}/PKG-INFO +1 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/PTT.py +2 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/__init__.py +1 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_change_pw.py +6 -5
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_give_money.py +1 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_loginout.py +3 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_mail.py +8 -2
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_reply_post.py +3 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt.egg-info/SOURCES.txt +1 -0
- pyptt-2.3.3/tests/test_backup_effective.py +403 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/LICENSE +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_del_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_set_signature_file.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_api_util.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_uao/LICENSE +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_uao/__init__.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_uao/b2u.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/_uao/u2b.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/api_server.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/check_value.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/command.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/config.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/connect_core.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/data_type.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/exceptions.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/i18n.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/lang_en_US.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/lang_zh_TW.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/lib_util.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/log.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/screens.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/service.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt/ssl_config.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/README.md +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/pyproject.toml +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/setup.cfg +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/setup.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_backup_param.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_bucket.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_change_pw.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_comment.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_connect_core_timeout.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_del_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_del_post_unit.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_exceptions.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_fast_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_board_info.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_board_list.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_mail.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_post_list.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_time.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_user.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_get_waterball.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_give_money.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_half_dead_connection.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_i18n.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_incremental_parity.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_init.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_logger.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_login_logout.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_mark_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_post_anonymous.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_reply_post.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_search_user.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_service.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_set_board_title.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_set_signature_file.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_uao_codec.py +0 -0
- {pyptt-2.3.1 → pyptt-2.3.3}/tests/test_vt100_parser.py +0 -0
|
@@ -677,7 +677,8 @@ class API:
|
|
|
677
677
|
sign_file (str | int): 編號或隨機簽名檔 (x),預設為 **0** (不選)。
|
|
678
678
|
aid: 文章編號。
|
|
679
679
|
index: 文章編號。
|
|
680
|
-
backup (bool): 回信給作者時是否自存底稿,預設為 True
|
|
680
|
+
backup (bool): 回信給作者時是否自存底稿,預設為 True。僅在 reply_to 為 MAIL 時有作用;
|
|
681
|
+
BOARD_MAIL 實測不會出現自存底稿提示,該路徑下此參數無效。
|
|
681
682
|
|
|
682
683
|
Returns:
|
|
683
684
|
None
|
|
@@ -13,8 +13,6 @@ def change_pw(api, new_password: str) -> None:
|
|
|
13
13
|
|
|
14
14
|
log.logger.info(i18n.change_pw)
|
|
15
15
|
|
|
16
|
-
new_password = new_password[:8]
|
|
17
|
-
|
|
18
16
|
cmd_list = []
|
|
19
17
|
cmd_list.append(command.go_main_menu)
|
|
20
18
|
cmd_list.append('U')
|
|
@@ -29,9 +27,12 @@ def change_pw(api, new_password: str) -> None:
|
|
|
29
27
|
connect_core.TargetUnit('設定聯絡信箱後才能修改密碼', exceptions_=exceptions.SetContactMailFirst()),
|
|
30
28
|
connect_core.TargetUnit('您輸入的密碼不正確', exceptions_=exceptions.WrongPassword()),
|
|
31
29
|
connect_core.TargetUnit('請您確定(Y/N)?', response='Y' + command.enter),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
# 只比對「新密碼」/「原密碼」, 不含前後綴與冒號: 正式站目前是「請設定新密碼:」
|
|
31
|
+
# 「請檢查新密碼:」「請輸入原密碼:」(全形冒號), pttbbs #112 之後上游改成兩步同名的
|
|
32
|
+
# 「新密碼:」(半形), 兩種寫法都涵蓋。新密碼要吃設定與確認兩次, 故 max_match=2。
|
|
33
|
+
# 「新密碼」必須排在「原密碼」前面: 新版輸入完原密碼後該行仍留在畫面上, 兩者會同時出現。
|
|
34
|
+
connect_core.TargetUnit('新密碼', response=new_password + command.enter, max_match=2, secret=True),
|
|
35
|
+
connect_core.TargetUnit('原密碼', response=api._ptt_pw + command.enter, max_match=1, secret=True),
|
|
35
36
|
connect_core.TargetUnit('設定個人資料與密碼', break_detect=True)
|
|
36
37
|
]
|
|
37
38
|
|
|
@@ -78,7 +78,7 @@ def give_money(api, ptt_id: str, money: int, red_bag_title: str, red_bag_content
|
|
|
78
78
|
connect_core.TargetUnit('按任意鍵繼續', break_detect=True),
|
|
79
79
|
edit_red_bag_target,
|
|
80
80
|
connect_core.TargetUnit('要修改紅包袋嗎', response=command.enter),
|
|
81
|
-
connect_core.TargetUnit('完成交易前要重新確認您的身份', response=api._ptt_pw + command.enter),
|
|
81
|
+
connect_core.TargetUnit('完成交易前要重新確認您的身份', response=api._ptt_pw + command.enter, secret=True),
|
|
82
82
|
connect_core.TargetUnit('他是你的小主人,是否匿名?', response='n' + command.enter),
|
|
83
83
|
connect_core.TargetUnit('要給他多少Ptt幣呢?', response=command.tab + str(money) + command.enter),
|
|
84
84
|
connect_core.TargetUnit('這位幸運兒的id', response=ptt_id + command.enter),
|
|
@@ -68,7 +68,9 @@ def login(api, ptt_id: str, ptt_pw: str, kick_other_session: bool) -> None:
|
|
|
68
68
|
api.process_picks = int(pattern.search(screen).group(0))
|
|
69
69
|
|
|
70
70
|
ptt_id = ptt_id[:12].strip()
|
|
71
|
-
|
|
71
|
+
# 不截斷密碼: 舊版 PTT 由 server 端忽略第 8 字之後的部分, 新版 (pttbbs #112, bcrypt)
|
|
72
|
+
# 則支援 72 字元, 兩邊都必須送出完整密碼才會正確。
|
|
73
|
+
ptt_pw = ptt_pw.strip()
|
|
72
74
|
|
|
73
75
|
api.ptt_id = ptt_id
|
|
74
76
|
api._ptt_pw = ptt_pw
|
|
@@ -94,8 +94,14 @@ def mail(api,
|
|
|
94
94
|
connect_core.TargetUnit('請按任意鍵繼續', response=command.enter, break_detect_after_send=True),
|
|
95
95
|
connect_core.TargetUnit('確定要儲存檔案嗎', response='s' + command.enter),
|
|
96
96
|
connect_core.TargetUnit('是否自存底稿', response=('y' if backup else 'n') + command.enter),
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
# max_match=1 是防禦性上限,簽名檔提示只該被回答一次。真 PTT 與本地 pttbbs 都
|
|
98
|
+
# 實測過(monkeypatch TargetUnit.is_match 計數):寄信流程從「確定要儲存檔案嗎」
|
|
99
|
+
# 直接跳到「已順利寄出,是否自存底稿」,這個 target 一次都沒命中,推測要帳號設過
|
|
100
|
+
# 簽名檔才會出現。假說是沒有上限時提示殘留在重繪畫面上會二次命中、多送的 enter
|
|
101
|
+
# 被自存底稿當成預設 Y 吃掉讓 backup 失效——該情境尚未實際復現,上限先留著。
|
|
102
|
+
# 巢狀 list = 命中任一字串;'選擇簽名檔' 與 'x=隨機' 是同一行提示的兩種寫法。
|
|
103
|
+
connect_core.TargetUnit([['選擇簽名檔', 'x=隨機']],
|
|
104
|
+
response=str(sign_file) + command.enter, max_match=1),
|
|
99
105
|
]
|
|
100
106
|
|
|
101
107
|
# 送出訊息
|
|
@@ -80,7 +80,9 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
|
|
|
80
80
|
connect_core.TargetUnit('◆ 很抱歉, 此文章已結案並標記, 不得回應', log_level=log.INFO,
|
|
81
81
|
exceptions_=exceptions.CantResponse()),
|
|
82
82
|
connect_core.TargetUnit('(E)繼續編輯 (W)強制寫入', log_level=log.INFO, response='W' + command.enter),
|
|
83
|
-
|
|
83
|
+
# max_match=1 是防禦性上限,理由同 _api_mail.py。三種 reply_to(BOARD / MAIL /
|
|
84
|
+
# BOARD_MAIL)都對真 PTT 實測過,這個 target 一次都沒命中,目前執行不到。
|
|
85
|
+
connect_core.TargetUnit('請選擇簽名檔', response=str(sign_file) + command.enter, max_match=1),
|
|
84
86
|
connect_core.TargetUnit('確定要儲存檔案嗎', response='s' + command.enter),
|
|
85
87
|
connect_core.TargetUnit('編輯文章', log_level=log.INFO,
|
|
86
88
|
response=str(content) + command.enter + command.ctrl_x),
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Regression test: `mail(backup=False)` / `reply_post(backup=False)` must
|
|
2
|
+
NOT leave a draft behind in the sender's own mailbox.
|
|
3
|
+
|
|
4
|
+
Bug recap: the '選擇簽名檔' / '請選擇簽名檔' prompt line isn't overwritten by
|
|
5
|
+
the following "已順利寄出,是否自存底稿" prompt (they print on different
|
|
6
|
+
lines), so on the next screen redraw the signature-file TargetUnit matched a
|
|
7
|
+
second time and sent an extra Enter -- which the backup prompt then consumed
|
|
8
|
+
as its default [Y]. That silently saved a draft regardless of what `backup=`
|
|
9
|
+
was set to. The fix is `max_match=1` on that TargetUnit (`_api_mail.py`,
|
|
10
|
+
`_api_reply_post.py`), which caps it to firing once per screen.
|
|
11
|
+
|
|
12
|
+
Core signal: the sender's own mailbox newest index (`get_newest_index`).
|
|
13
|
+
backup=False must leave it unchanged; backup=True must bump it by exactly 1
|
|
14
|
+
(the self-saved draft).
|
|
15
|
+
|
|
16
|
+
This must run against real PTT (PTT1), not the local imageptt container: the
|
|
17
|
+
local container doesn't reliably reproduce the signature-file prompt redraw,
|
|
18
|
+
so it has no discriminating power there. `ptt_bots[0]` / `ptt_bots[1]` are
|
|
19
|
+
NOT usable as sender/recipient here -- PTT1_ID and PTT2_ID are the same
|
|
20
|
+
account (CodingMan) on two different hosts (PTT1=批踢踢實業坊,
|
|
21
|
+
PTT2=批踢踢兔), not two distinct accounts. Mailing yourself would merge the
|
|
22
|
+
"received" signal with the "draft" signal and defeat the mailbox-index
|
|
23
|
+
assertion. The sender is `ptt_bots[0]` (CodingMan on PTT1); the recipient is
|
|
24
|
+
a second, genuinely distinct PTT1 account (DeepLearning, via the
|
|
25
|
+
`deep_learning_bot` fixture below).
|
|
26
|
+
"""
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
import warnings
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
import PyPtt
|
|
34
|
+
from PyPtt import MailField, NewIndex, PostField, ReplyTo
|
|
35
|
+
from PyPtt import connect_core
|
|
36
|
+
|
|
37
|
+
MAIL_DELIVERY_WAIT = 3 # > TimedDict(timeout=2) on api._newest_index_data
|
|
38
|
+
|
|
39
|
+
DEEPLEARNING_ID = os.environ.get('DEEPLEARNING_ID')
|
|
40
|
+
DEEPLEARNING_PW = os.environ.get('DEEPLEARNING_PW')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture(scope='module')
|
|
44
|
+
def deep_learning_bot():
|
|
45
|
+
"""A second, distinct PTT1 account (DeepLearning) to act as mail
|
|
46
|
+
recipient / reply target author. Skips the whole module when the
|
|
47
|
+
credentials aren't configured rather than failing with a KeyError."""
|
|
48
|
+
if not DEEPLEARNING_ID or not DEEPLEARNING_PW:
|
|
49
|
+
pytest.skip('DEEPLEARNING_ID / DEEPLEARNING_PW not set; skipping '
|
|
50
|
+
'real-PTT backup-effectiveness test')
|
|
51
|
+
|
|
52
|
+
bot = PyPtt.API(host=PyPtt.HOST.PTT1)
|
|
53
|
+
bot.login(ptt_id=DEEPLEARNING_ID, ptt_pw=DEEPLEARNING_PW, kick_other_session=True)
|
|
54
|
+
yield bot
|
|
55
|
+
try:
|
|
56
|
+
bot.logout()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _mailbox_index(bot):
|
|
62
|
+
return bot.get_newest_index(index_type=NewIndex.MAIL)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_and_delete_mail(bot, title_fragment, search_back=5):
|
|
66
|
+
"""Best-effort cleanup: find a mail whose title contains
|
|
67
|
+
`title_fragment` near the mailbox's newest entry and delete it. Never
|
|
68
|
+
raises -- this is container hygiene, not part of the assertion."""
|
|
69
|
+
try:
|
|
70
|
+
newest = bot.get_newest_index(index_type=NewIndex.MAIL)
|
|
71
|
+
for i in range(search_back):
|
|
72
|
+
index = newest - i
|
|
73
|
+
if index <= 0:
|
|
74
|
+
continue
|
|
75
|
+
mail_info = bot.get_mail(index)
|
|
76
|
+
if title_fragment in (mail_info[MailField.title] or ''):
|
|
77
|
+
bot.del_mail(index)
|
|
78
|
+
return
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_mail_backup_false_leaves_mailbox_unchanged(ptt_bots, deep_learning_bot):
|
|
84
|
+
bot1 = ptt_bots[0]
|
|
85
|
+
title = f'PyPtt backup=False mail test {int(time.time())}'
|
|
86
|
+
before = _mailbox_index(bot1)
|
|
87
|
+
try:
|
|
88
|
+
bot1.mail(ptt_id=deep_learning_bot.ptt_id, title=title,
|
|
89
|
+
content='backup=False regression test\n', sign_file=0, backup=False)
|
|
90
|
+
time.sleep(MAIL_DELIVERY_WAIT)
|
|
91
|
+
after = _mailbox_index(bot1)
|
|
92
|
+
assert after == before, (
|
|
93
|
+
f'mail(backup=False) saved a draft anyway: mailbox index {before} -> {after}'
|
|
94
|
+
)
|
|
95
|
+
finally:
|
|
96
|
+
_find_and_delete_mail(bot1, title)
|
|
97
|
+
_find_and_delete_mail(deep_learning_bot, title)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_mail_backup_true_saves_draft(ptt_bots, deep_learning_bot):
|
|
101
|
+
bot1 = ptt_bots[0]
|
|
102
|
+
title = f'PyPtt backup=True mail test {int(time.time())}'
|
|
103
|
+
before = _mailbox_index(bot1)
|
|
104
|
+
try:
|
|
105
|
+
bot1.mail(ptt_id=deep_learning_bot.ptt_id, title=title,
|
|
106
|
+
content='backup=True regression test\n', sign_file=0, backup=True)
|
|
107
|
+
time.sleep(MAIL_DELIVERY_WAIT)
|
|
108
|
+
after = _mailbox_index(bot1)
|
|
109
|
+
assert after == before + 1, (
|
|
110
|
+
f'mail(backup=True) did not save a draft: mailbox index {before} -> {after}'
|
|
111
|
+
)
|
|
112
|
+
finally:
|
|
113
|
+
_find_and_delete_mail(bot1, title)
|
|
114
|
+
_find_and_delete_mail(deep_learning_bot, title)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _find_and_delete_post(bot, title_fragment, board='Test', search_back=10):
|
|
118
|
+
"""Best-effort cleanup: find a post whose title contains
|
|
119
|
+
`title_fragment` near the board's newest entry and delete it. Never
|
|
120
|
+
raises -- this is container hygiene, not part of the assertion."""
|
|
121
|
+
try:
|
|
122
|
+
newest = bot.get_newest_index(index_type=NewIndex.BOARD, board=board)
|
|
123
|
+
for i in range(search_back):
|
|
124
|
+
index = newest - i
|
|
125
|
+
if index <= 0:
|
|
126
|
+
continue
|
|
127
|
+
post = bot.get_post(board=board, index=index, query=True)
|
|
128
|
+
if post and title_fragment in (post.get(PostField.title, '') or ''):
|
|
129
|
+
bot.del_post(board=board, index=index)
|
|
130
|
+
return
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _post_reply_target(deep_learning_bot, bot1, board='Test'):
|
|
136
|
+
"""DeepLearning posts a fresh article on `board` so bot1 (CodingMan) can
|
|
137
|
+
reply to it as mail. The author must not be bot1 -- replying to your own
|
|
138
|
+
post as mail would land in your own mailbox and be indistinguishable
|
|
139
|
+
from a backup draft. Returns the post's index (as seen by bot1).
|
|
140
|
+
|
|
141
|
+
If the freshly posted article can't be located afterwards, the article
|
|
142
|
+
has already gone out for real -- so this makes a best-effort attempt to
|
|
143
|
+
delete it (by title match, scanning the board) before re-raising,
|
|
144
|
+
instead of leaving an orphaned post on real PTT with no cleanup path."""
|
|
145
|
+
# Millisecond resolution folded back into a 10-digit window -- same
|
|
146
|
+
# width as the old int(time.time()) suffix. PTT's query-popup title
|
|
147
|
+
# line truncates past a certain column width (confirmed empirically:
|
|
148
|
+
# a full time.time_ns() suffix got silently cut, breaking the
|
|
149
|
+
# endswith()/in matches below), so this must NOT grow the title.
|
|
150
|
+
suffix = int(time.time() * 1000) % 10_000_000_000
|
|
151
|
+
title = f'PyPtt backup reply target {suffix}'
|
|
152
|
+
deep_learning_bot.post(board=board, title_index=1, title=title,
|
|
153
|
+
content='reply-target for backup regression test\n', sign_file=0)
|
|
154
|
+
time.sleep(2)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
newest = bot1.get_newest_index(index_type=NewIndex.BOARD, board=board)
|
|
158
|
+
for i in range(5):
|
|
159
|
+
index = newest - i
|
|
160
|
+
if index <= 0:
|
|
161
|
+
continue
|
|
162
|
+
post = bot1.get_post(board=board, index=index, query=True)
|
|
163
|
+
if post and post.get(PostField.title, '').endswith(title):
|
|
164
|
+
return title, index
|
|
165
|
+
raise AssertionError(f'freshly posted reply target {title!r} not found on {board}')
|
|
166
|
+
except Exception:
|
|
167
|
+
_find_and_delete_post(deep_learning_bot, title, board=board)
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_reply_post_backup_false_leaves_mailbox_unchanged(ptt_bots, deep_learning_bot):
|
|
172
|
+
bot1 = ptt_bots[0]
|
|
173
|
+
target_title, index = _post_reply_target(deep_learning_bot, bot1)
|
|
174
|
+
try:
|
|
175
|
+
before = _mailbox_index(bot1)
|
|
176
|
+
bot1.reply_post(reply_to=ReplyTo.MAIL, board='Test',
|
|
177
|
+
content='backup=False reply regression test\n',
|
|
178
|
+
index=index, backup=False)
|
|
179
|
+
time.sleep(MAIL_DELIVERY_WAIT)
|
|
180
|
+
after = _mailbox_index(bot1)
|
|
181
|
+
assert after == before, (
|
|
182
|
+
f'reply_post(backup=False) saved a draft anyway: mailbox index {before} -> {after}'
|
|
183
|
+
)
|
|
184
|
+
finally:
|
|
185
|
+
_find_and_delete_mail(bot1, target_title)
|
|
186
|
+
_find_and_delete_mail(deep_learning_bot, target_title)
|
|
187
|
+
try:
|
|
188
|
+
deep_learning_bot.del_post(board='Test', index=index)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_reply_post_backup_true_saves_draft(ptt_bots, deep_learning_bot):
|
|
194
|
+
bot1 = ptt_bots[0]
|
|
195
|
+
target_title, index = _post_reply_target(deep_learning_bot, bot1)
|
|
196
|
+
try:
|
|
197
|
+
before = _mailbox_index(bot1)
|
|
198
|
+
bot1.reply_post(reply_to=ReplyTo.MAIL, board='Test',
|
|
199
|
+
content='backup=True reply regression test\n',
|
|
200
|
+
index=index, backup=True)
|
|
201
|
+
time.sleep(MAIL_DELIVERY_WAIT)
|
|
202
|
+
after = _mailbox_index(bot1)
|
|
203
|
+
assert after == before + 1, (
|
|
204
|
+
f'reply_post(backup=True) did not save a draft: mailbox index {before} -> {after}'
|
|
205
|
+
)
|
|
206
|
+
finally:
|
|
207
|
+
_find_and_delete_mail(bot1, target_title)
|
|
208
|
+
_find_and_delete_mail(deep_learning_bot, target_title)
|
|
209
|
+
try:
|
|
210
|
+
deep_learning_bot.del_post(board='Test', index=index)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --- Regression check: does max_match=1 break ReplyTo.BOARD / BOARD_MAIL? ---
|
|
216
|
+
#
|
|
217
|
+
# The `max_match=1` cap on the '請選擇簽名檔' TargetUnit
|
|
218
|
+
# (`_api_reply_post.py`) is shared by all three ReplyTo branches. It was only
|
|
219
|
+
# verified against ReplyTo.MAIL so far. ReplyTo.BOARD and ReplyTo.BOARD_MAIL
|
|
220
|
+
# are the branches that actually post to a board -- and posting is the flow
|
|
221
|
+
# that shows a REAL '請選擇簽名檔' prompt (unlike plain mail). BOARD_MAIL
|
|
222
|
+
# additionally sends mail on top of the board post, so it's the branch most
|
|
223
|
+
# likely to hit the prompt twice for genuinely different reasons rather than
|
|
224
|
+
# just a leftover-redraw. If max_match=1 swallowed a real second prompt, the
|
|
225
|
+
# screen would never see a matching TargetUnit and send() would silently
|
|
226
|
+
# time out (it returns -1; reply_post() does not check the return value or
|
|
227
|
+
# raise on it) instead of raising cleanly.
|
|
228
|
+
#
|
|
229
|
+
# These tests assert on real side effects (reply lands on the board, mail is
|
|
230
|
+
# delivered) rather than trusting a clean return from reply_post(), and they
|
|
231
|
+
# also directly trace how many times '請選擇簽名檔' is seen during send().
|
|
232
|
+
|
|
233
|
+
REPLY_APPEAR_WAIT = 2 # board listing propagation delay, mirrors _post_reply_target
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.fixture
|
|
237
|
+
def sign_file_trace(monkeypatch):
|
|
238
|
+
"""Wrap TargetUnit.is_match so every round where the '請選擇簽名檔'
|
|
239
|
+
TargetUnit is evaluated gets recorded: whether the string was actually
|
|
240
|
+
present in that round's screen, and whether is_match returned True for
|
|
241
|
+
it (i.e. wasn't suppressed by max_match=1). Returns the log list."""
|
|
242
|
+
log = []
|
|
243
|
+
original_is_match = connect_core.TargetUnit.is_match
|
|
244
|
+
|
|
245
|
+
def traced_is_match(self, screen, cursor):
|
|
246
|
+
matched = original_is_match(self, screen, cursor)
|
|
247
|
+
if self.detect_target == '請選擇簽名檔':
|
|
248
|
+
log.append({
|
|
249
|
+
'present_in_screen': '請選擇簽名檔' in screen,
|
|
250
|
+
'matched': matched,
|
|
251
|
+
})
|
|
252
|
+
return matched
|
|
253
|
+
|
|
254
|
+
monkeypatch.setattr(connect_core.TargetUnit, 'is_match', traced_is_match)
|
|
255
|
+
return log
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _find_reply_index(bot, target_title, board='Test', search_back=8):
|
|
259
|
+
"""Find the 'R: ...<target_title>' reply authored by `bot` near the top
|
|
260
|
+
of `board`. Returns the index, or None if not found."""
|
|
261
|
+
newest = bot.get_newest_index(index_type=NewIndex.BOARD, board=board)
|
|
262
|
+
for i in range(search_back):
|
|
263
|
+
index = newest - i
|
|
264
|
+
if index <= 0:
|
|
265
|
+
continue
|
|
266
|
+
post = bot.get_post(board=board, index=index, query=True)
|
|
267
|
+
if not post:
|
|
268
|
+
continue
|
|
269
|
+
title = post.get(PostField.title, '') or ''
|
|
270
|
+
if not (title.startswith('R: ') and title.endswith(target_title)):
|
|
271
|
+
continue
|
|
272
|
+
author = (post.get(PostField.author, '') or '').split(' ')[0]
|
|
273
|
+
if author.lower() == bot.ptt_id.lower():
|
|
274
|
+
return index
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _assert_no_stalled_round(bot, elapsed, label):
|
|
279
|
+
"""A genuinely stuck screen round burns a full screen_long_timeout
|
|
280
|
+
(default 10s) of dead time before send() gives up and returns -1. A
|
|
281
|
+
healthy multi-round reply_post() exchange over the real network finishes
|
|
282
|
+
well under that, so total elapsed creeping up to it is the signature of
|
|
283
|
+
exactly the regression this test is hunting for."""
|
|
284
|
+
timeout = bot.config.screen_long_timeout
|
|
285
|
+
assert elapsed < timeout, (
|
|
286
|
+
f'{label} took {elapsed:.1f}s (screen_long_timeout={timeout}s) -- '
|
|
287
|
+
f'looks like a stalled screen round, not normal network latency'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_reply_post_board_appears_on_board(ptt_bots, deep_learning_bot, sign_file_trace):
|
|
292
|
+
bot1 = ptt_bots[0]
|
|
293
|
+
target_title, target_index = _post_reply_target(deep_learning_bot, bot1)
|
|
294
|
+
reply_index = None
|
|
295
|
+
try:
|
|
296
|
+
start = time.time()
|
|
297
|
+
bot1.reply_post(reply_to=ReplyTo.BOARD, board='Test',
|
|
298
|
+
content='ReplyTo.BOARD regression test\n',
|
|
299
|
+
index=target_index, sign_file=0)
|
|
300
|
+
elapsed = time.time() - start
|
|
301
|
+
_assert_no_stalled_round(bot1, elapsed, 'reply_post(BOARD)')
|
|
302
|
+
|
|
303
|
+
time.sleep(REPLY_APPEAR_WAIT)
|
|
304
|
+
reply_index = _find_reply_index(bot1, target_title)
|
|
305
|
+
assert reply_index is not None, (
|
|
306
|
+
f'reply to {target_title!r} not found on Test board -- reply_post(BOARD) '
|
|
307
|
+
f'silently did not complete'
|
|
308
|
+
)
|
|
309
|
+
finally:
|
|
310
|
+
# Cleanup checks below only warn, never assert/raise: if the test
|
|
311
|
+
# body above already failed, a cleanup hiccup here must not
|
|
312
|
+
# overwrite that original failure with a cleanup-only one.
|
|
313
|
+
if reply_index is not None:
|
|
314
|
+
try:
|
|
315
|
+
bot1.del_post(board='Test', index=reply_index)
|
|
316
|
+
post = bot1.get_post(board='Test', index=reply_index, query=True)
|
|
317
|
+
if post and post.get(PostField.post_status) == PyPtt.PostStatus.EXISTS:
|
|
318
|
+
warnings.warn(f'cleanup failed: reply post at index {reply_index} still EXISTS')
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
warnings.warn(f'cleanup failed for reply post at index {reply_index}: {exc!r}')
|
|
321
|
+
try:
|
|
322
|
+
deep_learning_bot.del_post(board='Test', index=target_index)
|
|
323
|
+
post = deep_learning_bot.get_post(board='Test', index=target_index, query=True)
|
|
324
|
+
if post and post.get(PostField.post_status) == PyPtt.PostStatus.EXISTS:
|
|
325
|
+
warnings.warn(f'cleanup failed: target post at index {target_index} still EXISTS')
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
warnings.warn(f'cleanup failed for target post at index {target_index}: {exc!r}')
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _reply_post_board_mail(ptt_bots, deep_learning_bot, sign_file_trace, backup):
|
|
331
|
+
bot1 = ptt_bots[0]
|
|
332
|
+
target_title, target_index = _post_reply_target(deep_learning_bot, bot1)
|
|
333
|
+
reply_index = None
|
|
334
|
+
try:
|
|
335
|
+
codingman_before = _mailbox_index(bot1)
|
|
336
|
+
deeplearning_before = _mailbox_index(deep_learning_bot)
|
|
337
|
+
|
|
338
|
+
start = time.time()
|
|
339
|
+
bot1.reply_post(reply_to=ReplyTo.BOARD_MAIL, board='Test',
|
|
340
|
+
content=f'ReplyTo.BOARD_MAIL backup={backup} regression test\n',
|
|
341
|
+
index=target_index, sign_file=0, backup=backup)
|
|
342
|
+
elapsed = time.time() - start
|
|
343
|
+
_assert_no_stalled_round(bot1, elapsed, f'reply_post(BOARD_MAIL, backup={backup})')
|
|
344
|
+
|
|
345
|
+
appearances = sum(1 for e in sign_file_trace if e['present_in_screen'])
|
|
346
|
+
matches = sum(1 for e in sign_file_trace if e['matched'])
|
|
347
|
+
print(f'[sign_file_trace backup={backup}] '
|
|
348
|
+
f'請選擇簽名檔 appeared in {appearances} round(s), matched {matches} of them: '
|
|
349
|
+
f'{sign_file_trace}')
|
|
350
|
+
|
|
351
|
+
time.sleep(REPLY_APPEAR_WAIT)
|
|
352
|
+
reply_index = _find_reply_index(bot1, target_title)
|
|
353
|
+
assert reply_index is not None, (
|
|
354
|
+
f'reply to {target_title!r} not found on Test board -- '
|
|
355
|
+
f'reply_post(BOARD_MAIL, backup={backup}) silently did not complete'
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
time.sleep(MAIL_DELIVERY_WAIT)
|
|
359
|
+
deeplearning_after = _mailbox_index(deep_learning_bot)
|
|
360
|
+
assert deeplearning_after == deeplearning_before + 1, (
|
|
361
|
+
f'DeepLearning did not receive the BOARD_MAIL reply: mailbox index '
|
|
362
|
+
f'{deeplearning_before} -> {deeplearning_after}'
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# NOTE (discovered while writing this test, not a max_match=1 effect):
|
|
366
|
+
# unlike ReplyTo.MAIL, pttbbs never shows '已順利寄出,是否自存底稿'
|
|
367
|
+
# for ReplyTo.BOARD_MAIL at all (confirmed by tracing every matched
|
|
368
|
+
# TargetUnit for this call: '▲ 回應至' -> '採用原標題[Y/n]?' ->
|
|
369
|
+
# '請問要引用原文嗎' -> '編輯文章' -> '確定要儲存檔案嗎' ->
|
|
370
|
+
# '任意鍵繼續', with neither '請選擇簽名檔' nor the backup prompt
|
|
371
|
+
# ever appearing). So `backup=` has no observable effect on
|
|
372
|
+
# BOARD_MAIL's own mailbox -- it stays unchanged either way. This
|
|
373
|
+
# contradicts the docstring's "僅在回覆類型含站內信時有作用" implying
|
|
374
|
+
# BOARD_MAIL should behave like MAIL here; flagging as a separate,
|
|
375
|
+
# pre-existing discrepancy rather than silently asserting the
|
|
376
|
+
# (unverified) assumption.
|
|
377
|
+
codingman_after = _mailbox_index(bot1)
|
|
378
|
+
assert codingman_after == codingman_before, (
|
|
379
|
+
f'reply_post(BOARD_MAIL, backup={backup}) changed the mailbox index '
|
|
380
|
+
f'{codingman_before} -> {codingman_after} -- if this now fires with '
|
|
381
|
+
f'backup=True, the no-backup-prompt assumption above no longer holds '
|
|
382
|
+
f'and this assertion needs revisiting'
|
|
383
|
+
)
|
|
384
|
+
finally:
|
|
385
|
+
if reply_index is not None:
|
|
386
|
+
try:
|
|
387
|
+
bot1.del_post(board='Test', index=reply_index)
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
try:
|
|
391
|
+
deep_learning_bot.del_post(board='Test', index=target_index)
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
_find_and_delete_mail(deep_learning_bot, target_title)
|
|
395
|
+
_find_and_delete_mail(bot1, target_title)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_reply_post_board_mail_backup_false(ptt_bots, deep_learning_bot, sign_file_trace):
|
|
399
|
+
_reply_post_board_mail(ptt_bots, deep_learning_bot, sign_file_trace, backup=False)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_reply_post_board_mail_backup_true(ptt_bots, deep_learning_bot, sign_file_trace):
|
|
403
|
+
_reply_post_board_mail(ptt_bots, deep_learning_bot, sign_file_trace, backup=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|