PyPtt 2.3.1__tar.gz → 2.3.2__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.
Files changed (103) hide show
  1. {pyptt-2.3.1 → pyptt-2.3.2}/PKG-INFO +1 -1
  2. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/PTT.py +2 -1
  3. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_mail.py +8 -2
  5. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_reply_post.py +3 -1
  6. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt.egg-info/PKG-INFO +1 -1
  7. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt.egg-info/SOURCES.txt +1 -0
  8. pyptt-2.3.2/tests/test_backup_effective.py +403 -0
  9. {pyptt-2.3.1 → pyptt-2.3.2}/LICENSE +0 -0
  10. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_bucket.py +0 -0
  11. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_call_status.py +0 -0
  12. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_change_pw.py +0 -0
  13. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_comment.py +0 -0
  14. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_del_post.py +0 -0
  15. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_board_info.py +0 -0
  16. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_board_list.py +0 -0
  17. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_bottom_post_list.py +0 -0
  18. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_favourite_board.py +0 -0
  19. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_newest_index.py +0 -0
  20. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_post.py +0 -0
  21. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_post_index.py +0 -0
  22. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_post_list.py +0 -0
  23. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_time.py +0 -0
  24. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_user.py +0 -0
  25. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_get_waterball.py +0 -0
  26. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_give_money.py +0 -0
  27. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_has_new_mail.py +0 -0
  28. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_loginout.py +0 -0
  29. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_mark_post.py +0 -0
  30. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_post.py +0 -0
  31. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_search_user.py +0 -0
  32. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_set_board_title.py +0 -0
  33. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_set_signature_file.py +0 -0
  34. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_api_util.py +0 -0
  35. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_uao/LICENSE +0 -0
  36. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_uao/__init__.py +0 -0
  37. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_uao/b2u.py +0 -0
  38. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/_uao/u2b.py +0 -0
  39. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/api_server.py +0 -0
  40. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/check_value.py +0 -0
  41. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/command.py +0 -0
  42. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/config.py +0 -0
  43. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/connect_core.py +0 -0
  44. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/data_type.py +0 -0
  45. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/exceptions.py +0 -0
  46. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/i18n.py +0 -0
  47. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/lang_en_US.py +0 -0
  48. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/lang_zh_TW.py +0 -0
  49. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/lib_util.py +0 -0
  50. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/log.py +0 -0
  51. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/screens.py +0 -0
  52. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/service.py +0 -0
  53. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt/ssl_config.py +0 -0
  54. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt.egg-info/dependency_links.txt +0 -0
  55. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt.egg-info/requires.txt +0 -0
  56. {pyptt-2.3.1 → pyptt-2.3.2}/PyPtt.egg-info/top_level.txt +0 -0
  57. {pyptt-2.3.1 → pyptt-2.3.2}/README.md +0 -0
  58. {pyptt-2.3.1 → pyptt-2.3.2}/pyproject.toml +0 -0
  59. {pyptt-2.3.1 → pyptt-2.3.2}/setup.cfg +0 -0
  60. {pyptt-2.3.1 → pyptt-2.3.2}/setup.py +0 -0
  61. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_backup_param.py +0 -0
  62. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_bucket.py +0 -0
  63. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_change_pw.py +0 -0
  64. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_comment.py +0 -0
  65. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_connect_core_timeout.py +0 -0
  66. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_del_post.py +0 -0
  67. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_del_post_unit.py +0 -0
  68. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_exceptions.py +0 -0
  69. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_fast_post.py +0 -0
  70. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_board_info.py +0 -0
  71. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_board_list.py +0 -0
  72. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_bottom_post_list.py +0 -0
  73. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_content_formula.py +0 -0
  74. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_favourite_boards.py +0 -0
  75. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_mail.py +0 -0
  76. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_newest_index.py +0 -0
  77. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_post.py +0 -0
  78. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_post_list.py +0 -0
  79. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_post_list_parser.py +0 -0
  80. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_post_parser.py +0 -0
  81. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_time.py +0 -0
  82. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_user.py +0 -0
  83. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_get_waterball.py +0 -0
  84. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_give_money.py +0 -0
  85. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_half_dead_connection.py +0 -0
  86. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_i18n.py +0 -0
  87. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_incremental_parity.py +0 -0
  88. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_init.py +0 -0
  89. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_logger.py +0 -0
  90. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_login_logout.py +0 -0
  91. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_mail_send_and_del.py +0 -0
  92. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_mark_post.py +0 -0
  93. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_parse_query_post.py +0 -0
  94. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_post.py +0 -0
  95. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_post_anonymous.py +0 -0
  96. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_pyte_parity.py +0 -0
  97. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_reply_post.py +0 -0
  98. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_search_user.py +0 -0
  99. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_service.py +0 -0
  100. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_set_board_title.py +0 -0
  101. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_set_signature_file.py +0 -0
  102. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_uao_codec.py +0 -0
  103. {pyptt-2.3.1 → pyptt-2.3.2}/tests/test_vt100_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -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
@@ -1,4 +1,4 @@
1
- __version__ = '2.3.1'
1
+ __version__ = '2.3.2'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -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
- connect_core.TargetUnit('選擇簽名檔', response=str(sign_file) + command.enter),
98
- connect_core.TargetUnit('x=隨機', response=str(sign_file) + command.enter),
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
- connect_core.TargetUnit('請選擇簽名檔', response=str(sign_file) + command.enter),
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),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -55,6 +55,7 @@ PyPtt/_uao/LICENSE
55
55
  PyPtt/_uao/__init__.py
56
56
  PyPtt/_uao/b2u.py
57
57
  PyPtt/_uao/u2b.py
58
+ tests/test_backup_effective.py
58
59
  tests/test_backup_param.py
59
60
  tests/test_bucket.py
60
61
  tests/test_change_pw.py
@@ -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