PyPtt 2.3.16__tar.gz → 2.3.18__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.16 → pyptt-2.3.18}/PKG-INFO +1 -1
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/PTT.py +2 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/__init__.py +1 -1
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_newest_index.py +11 -38
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_post.py +6 -9
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_post_list.py +27 -6
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_mark_post.py +6 -16
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_util.py +155 -10
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/screens.py +17 -1
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt.egg-info/SOURCES.txt +1 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_post_list_parser.py +10 -0
- pyptt-2.3.18/tests/test_goto_board_same_board.py +231 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_half_dead_connection.py +4 -4
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_incremental_parity.py +2 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_new_screen_fixes.py +3 -2
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_vt100_parser.py +23 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/LICENSE +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_del_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_api_set_signature_file.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_uao/LICENSE +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_uao/__init__.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_uao/b2u.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/_uao/u2b.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/api_server.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/check_value.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/command.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/config.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/connect_core.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/data_type.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/exceptions.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/i18n.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/lang_en_US.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/lang_zh_TW.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/lib_util.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/log.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/service.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt/ssl_config.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/README.md +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/pyproject.toml +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/setup.cfg +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/setup.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_backup_effective.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_backup_param.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_board_setting_flow.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_bucket.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_change_pw.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_comment.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_connect_core_timeout.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_del_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_del_post_unit.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_exceptions.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_fast_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_board_info.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_board_info_parser.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_board_list.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_mail.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_post_list.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_time.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_user.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_get_waterball.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_give_money.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_i18n.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_init.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_logger.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_login_logout.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_main_menu_status_bar.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_mark_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_post_anonymous.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_reply_post.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_search_user.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_service.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_set_board_title.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_set_signature_file.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_status_line.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_uao_codec.py +0 -0
- {pyptt-2.3.16 → pyptt-2.3.18}/tests/test_websocket_endpoint.py +0 -0
|
@@ -162,6 +162,8 @@ class API:
|
|
|
162
162
|
self._moderators = dict()
|
|
163
163
|
self._thread_id = threading.get_ident()
|
|
164
164
|
self._goto_board_list = set()
|
|
165
|
+
self._current_board = None
|
|
166
|
+
self._current_search = ()
|
|
165
167
|
self._board_info_list = dict()
|
|
166
168
|
self._newest_index_data = data_type.TimedDict(timeout=2)
|
|
167
169
|
self._mail_capacity = None
|
|
@@ -85,7 +85,8 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
|
|
|
85
85
|
check_value.check_type(board, str, 'board')
|
|
86
86
|
|
|
87
87
|
_api_util.check_board(api, board)
|
|
88
|
-
|
|
88
|
+
# 有搜尋條件時 goto_board 會停在搜尋結果裡;搜不到直接丟 NoSearchResult
|
|
89
|
+
_api_util.goto_board(api, board, search_list=search_list)
|
|
89
90
|
|
|
90
91
|
cmd_list = []
|
|
91
92
|
cmd_list.append('1')
|
|
@@ -97,10 +98,18 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
|
|
|
97
98
|
target_list = [
|
|
98
99
|
connect_core.TargetUnit('沒有文章...', log_level=log.DEBUG, break_detect=True),
|
|
99
100
|
connect_core.TargetUnit(screens.Target.InBoard, log_level=log.DEBUG, break_detect=True),
|
|
101
|
+
connect_core.TargetUnit(screens.Target.InSearchList, log_level=log.DEBUG, break_detect=True),
|
|
100
102
|
connect_core.TargetUnit(screens.Target.InBoardWithCursor, log_level=log.DEBUG, break_detect=True),
|
|
101
103
|
connect_core.TargetUnit(screens.Target.MainMenu_Exiting,
|
|
102
104
|
exceptions_=exceptions.NoSuchBoard(api.config, board)),
|
|
103
105
|
]
|
|
106
|
+
if search_list:
|
|
107
|
+
# 搜不到時 goto_board 已經丟 NoSearchResult;萬一提示框沒被認出來,'1\r' 會打開游標所在的文章
|
|
108
|
+
# (游標文是動畫文時先停在「要開始播放嗎?」),落在文章裡同樣是沒有結果
|
|
109
|
+
target_list.append(connect_core.TargetUnit(screens.Target.InPost,
|
|
110
|
+
exceptions_=exceptions.NoSearchResult()))
|
|
111
|
+
target_list.append(connect_core.TargetUnit(screens.Target.Animation,
|
|
112
|
+
exceptions_=exceptions.NoSearchResult()))
|
|
104
113
|
|
|
105
114
|
index = api.connect_core.send(cmd, target_list)
|
|
106
115
|
if index < 0:
|
|
@@ -111,43 +120,7 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
|
|
|
111
120
|
if index == 0:
|
|
112
121
|
return 0
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if search_list is not None and len(search_list) > 0:
|
|
117
|
-
target_list.insert(2,
|
|
118
|
-
connect_core.TargetUnit(
|
|
119
|
-
screens.Target.InBoardWithCursor,
|
|
120
|
-
log_level=log.DEBUG,
|
|
121
|
-
break_detect=True))
|
|
122
|
-
# 新版搜尋無結果時,後面的 '1\r' 會打開游標所在的文章;落在文章裡就是沒有結果
|
|
123
|
-
# 游標文是動畫文時會先停在「要開始播放嗎?」;同樣是沒有結果 (畫面留在詢問上,由下一支 API 的導航離開)
|
|
124
|
-
target_list.append(connect_core.TargetUnit(screens.Target.InPost,
|
|
125
|
-
exceptions_=exceptions.NoSearchResult()))
|
|
126
|
-
target_list.append(connect_core.TargetUnit(screens.Target.Animation,
|
|
127
|
-
exceptions_=exceptions.NoSearchResult()))
|
|
128
|
-
|
|
129
|
-
cmd_list = _api_util.get_search_condition_cmd(index_type, search_list)
|
|
130
|
-
|
|
131
|
-
cmd_list.append('1')
|
|
132
|
-
cmd_list.append(command.enter)
|
|
133
|
-
cmd_list.append('$')
|
|
134
|
-
cmd = ''.join(cmd_list)
|
|
135
|
-
|
|
136
|
-
index = api.connect_core.send(cmd, target_list)
|
|
137
|
-
if index < 0:
|
|
138
|
-
if api.connect_core.last_timeout_was_silent:
|
|
139
|
-
raise exceptions.ConnectionClosed()
|
|
140
|
-
raise exceptions.NoSuchBoard(api.config, board)
|
|
141
|
-
|
|
142
|
-
if index == 0:
|
|
143
|
-
return 0
|
|
144
|
-
|
|
145
|
-
newest_index = _get_newest_index(api)
|
|
146
|
-
|
|
147
|
-
if normal_newest_index == newest_index:
|
|
148
|
-
raise exceptions.NoSearchResult()
|
|
149
|
-
else:
|
|
150
|
-
newest_index = normal_newest_index
|
|
123
|
+
newest_index = _get_newest_index(api)
|
|
151
124
|
|
|
152
125
|
elif index_type == data_type.NewIndex.MAIL:
|
|
153
126
|
|
|
@@ -61,10 +61,8 @@ def get_post(api, board: str, aid: Optional[str] = None, index: Optional[int] =
|
|
|
61
61
|
else:
|
|
62
62
|
raise exceptions.ParameterError('wrong parameter index or aid must input')
|
|
63
63
|
|
|
64
|
-
search_cmd = None
|
|
65
64
|
if search_list is not None and len(search_list) > 0:
|
|
66
65
|
current_index = api.get_newest_index(data_type.NewIndex.BOARD, board=board, search_list=search_list)
|
|
67
|
-
search_cmd = _api_util.get_search_condition_cmd(data_type.NewIndex.BOARD, search_list)
|
|
68
66
|
else:
|
|
69
67
|
current_index = api.get_newest_index(data_type.NewIndex.BOARD, board=board)
|
|
70
68
|
|
|
@@ -75,7 +73,7 @@ def get_post(api, board: str, aid: Optional[str] = None, index: Optional[int] =
|
|
|
75
73
|
post = {}
|
|
76
74
|
for i in range(max_retry):
|
|
77
75
|
try:
|
|
78
|
-
post = _get_post(api, board, aid, index, query,
|
|
76
|
+
post = _get_post(api, board, aid, index, query, search_list)
|
|
79
77
|
if not post:
|
|
80
78
|
pass
|
|
81
79
|
elif not post[PostField.pass_format_check]:
|
|
@@ -97,9 +95,10 @@ def get_post(api, board: str, aid: Optional[str] = None, index: Optional[int] =
|
|
|
97
95
|
|
|
98
96
|
|
|
99
97
|
def _get_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0, query: bool = False,
|
|
100
|
-
|
|
98
|
+
search_list: Optional[list] = None) -> Dict:
|
|
101
99
|
_api_util.check_board(api, board)
|
|
102
|
-
|
|
100
|
+
# 用 AID 找文章時不需要搜尋條件;用編號時要停在同一組條件的搜尋結果裡,編號才對得上
|
|
101
|
+
_api_util.goto_board(api, board, search_list=search_list if post_aid is None else None)
|
|
103
102
|
|
|
104
103
|
cmd_list = []
|
|
105
104
|
|
|
@@ -107,9 +106,6 @@ def _get_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
|
|
|
107
106
|
cmd_list.append(lib_util.check_aid(post_aid))
|
|
108
107
|
elif post_index != 0:
|
|
109
108
|
|
|
110
|
-
if search_cmd_list is not None:
|
|
111
|
-
cmd_list.extend(search_cmd_list)
|
|
112
|
-
|
|
113
109
|
cmd_list.append(str(max(1, post_index - 100)))
|
|
114
110
|
cmd_list.append(command.enter)
|
|
115
111
|
cmd_list.append(str(post_index))
|
|
@@ -126,6 +122,7 @@ def _get_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
|
|
|
126
122
|
screens.Target.PTT1_QueryPost if api.config.host == data_type.HOST.PTT1 else screens.Target.PTT2_QueryPost,
|
|
127
123
|
log_level=log.DEBUG, break_detect=True, refresh=False),
|
|
128
124
|
connect_core.TargetUnit(screens.Target.InBoard, log_level=log.DEBUG, break_detect=True),
|
|
125
|
+
connect_core.TargetUnit(screens.Target.InSearchList, log_level=log.DEBUG, break_detect=True),
|
|
129
126
|
connect_core.TargetUnit(screens.Target.MainMenu_Exiting, exceptions_=exceptions.NoSuchBoard(api.config, board)),
|
|
130
127
|
]
|
|
131
128
|
|
|
@@ -155,7 +152,7 @@ def _get_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
|
|
|
155
152
|
PostField.is_unconfirmed: False,
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
if index < 0 or index
|
|
155
|
+
if index < 0 or index in (1, 2):
|
|
159
156
|
# Known gap (intentionally not fixed here): a connection that dies
|
|
160
157
|
# mid-redraw (bytes received, so last_timeout_was_silent is False)
|
|
161
158
|
# leaves a stale listing screen with a cursor line in RDQ, and
|
|
@@ -11,6 +11,24 @@ from . import log
|
|
|
11
11
|
from . import screens
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _cell_width(char: str) -> int:
|
|
15
|
+
# 終端機上全形字佔 2 格 (例如推文數的「爆」),用 big5uao 的位元組數當作顯示寬度
|
|
16
|
+
return len(char.encode('big5uao', 'replace'))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _split_columns(line: str, bounds: List[int]) -> List[str]:
|
|
20
|
+
"""依顯示欄位 (不是字元位置) 切開列表的一行。bounds 是各欄的起始欄位。"""
|
|
21
|
+
fields = [''] * len(bounds)
|
|
22
|
+
column = 0
|
|
23
|
+
for char in line:
|
|
24
|
+
for i in range(len(bounds) - 1, -1, -1):
|
|
25
|
+
if column >= bounds[i]:
|
|
26
|
+
fields[i] += char
|
|
27
|
+
break
|
|
28
|
+
column += _cell_width(char)
|
|
29
|
+
return fields
|
|
30
|
+
|
|
31
|
+
|
|
14
32
|
def get_post_list(api, board: str, limit: int = 20, offset: int = 0) -> List[Dict]:
|
|
15
33
|
_api_util.one_thread(api)
|
|
16
34
|
|
|
@@ -84,18 +102,21 @@ def get_post_list(api, board: str, limit: int = 20, offset: int = 0) -> List[Dic
|
|
|
84
102
|
# offsets here were tuned to that padded layout.
|
|
85
103
|
line = f' {line}'
|
|
86
104
|
|
|
105
|
+
# 欄位是固定的顯示寬度;推文數「爆」是全形字,用字元位置切會讓後面每一欄都偏一格
|
|
106
|
+
index_field, status, comment, list_date, _, author, title = _split_columns(
|
|
107
|
+
line, [0, 8, 10, 12, 17, 18, 31])
|
|
108
|
+
|
|
87
109
|
try:
|
|
88
|
-
cur_index = int(
|
|
110
|
+
cur_index = int(index_field.strip())
|
|
89
111
|
except ValueError:
|
|
90
112
|
# Pinned/announcement posts use '★' instead of a numeric
|
|
91
113
|
# index — they have no sequential index and cannot be
|
|
92
114
|
# fetched by index, so skip them.
|
|
93
115
|
continue
|
|
94
|
-
status =
|
|
95
|
-
comment =
|
|
96
|
-
list_date =
|
|
97
|
-
author =
|
|
98
|
-
title = line[31:]
|
|
116
|
+
status = status.strip()
|
|
117
|
+
comment = comment.strip()
|
|
118
|
+
list_date = list_date.strip()
|
|
119
|
+
author = author.strip()
|
|
99
120
|
|
|
100
121
|
cur_post = {
|
|
101
122
|
data_type.PostField.board: board,
|
|
@@ -79,28 +79,17 @@ def mark_post(api, mark_type: int, board: str, post_aid: str, post_index: int, s
|
|
|
79
79
|
board,
|
|
80
80
|
check_moderator=True)
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
search_list = None
|
|
83
|
+
if post_aid is None and search_condition is not None:
|
|
84
|
+
# 用編號找文章時要停在同一組條件的搜尋結果裡,編號才對得上
|
|
85
|
+
search_list = [(search_type, search_condition)]
|
|
86
|
+
_api_util.goto_board(api, board, search_list=search_list)
|
|
83
87
|
|
|
84
88
|
cmd_list = []
|
|
85
89
|
if post_aid is not None:
|
|
86
90
|
cmd_list.append(lib_util.check_aid(post_aid))
|
|
87
91
|
cmd_list.append(command.enter)
|
|
88
92
|
elif post_index != 0:
|
|
89
|
-
if search_condition is not None:
|
|
90
|
-
if search_type == data_type.SearchType.KEYWORD:
|
|
91
|
-
cmd_list.append('/')
|
|
92
|
-
elif search_type == data_type.SearchType.AUTHOR:
|
|
93
|
-
cmd_list.append('a')
|
|
94
|
-
elif search_type == data_type.SearchType.COMMENT:
|
|
95
|
-
cmd_list.append('Z')
|
|
96
|
-
elif search_type == data_type.SearchType.MARK:
|
|
97
|
-
cmd_list.append('G')
|
|
98
|
-
elif search_type == data_type.SearchType.MONEY:
|
|
99
|
-
cmd_list.append('A')
|
|
100
|
-
|
|
101
|
-
cmd_list.append(search_condition)
|
|
102
|
-
cmd_list.append(command.enter)
|
|
103
|
-
|
|
104
93
|
cmd_list.append(str(post_index))
|
|
105
94
|
|
|
106
95
|
cmd_list.append(command.enter)
|
|
@@ -123,6 +112,7 @@ def mark_post(api, mark_type: int, board: str, post_aid: str, post_index: int, s
|
|
|
123
112
|
target_list = [
|
|
124
113
|
connect_core.TargetUnit('刪除所有標記', log_level=log.INFO, response='y' + command.enter),
|
|
125
114
|
connect_core.TargetUnit(screens.Target.InBoard, log_level=log.INFO, break_detect=True),
|
|
115
|
+
connect_core.TargetUnit(screens.Target.InSearchList, log_level=log.INFO, break_detect=True),
|
|
126
116
|
]
|
|
127
117
|
|
|
128
118
|
api.connect_core.send(cmd, target_list)
|
|
@@ -547,7 +547,142 @@ def run_in_board_setting(api, flow):
|
|
|
547
547
|
raise
|
|
548
548
|
|
|
549
549
|
|
|
550
|
-
def
|
|
550
|
+
def search_key(search_list: Optional[list]) -> tuple:
|
|
551
|
+
# 搜尋條件的比對鍵;空 tuple 表示一般文章列表
|
|
552
|
+
return tuple((search_type, search_condition) for search_type, search_condition in (search_list or []))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def board_list_kind(api, screen: str, board: str) -> Optional[str]:
|
|
556
|
+
"""目前畫面是 board 的哪一種列表:'plain' 一般文章列表、'search' 搜尋結果,其他回傳 None。
|
|
557
|
+
|
|
558
|
+
一般列表標頭是「看板《Python》」;搜尋結果是「系列《Python》」。"""
|
|
559
|
+
if screens.in_board_setting(screen):
|
|
560
|
+
return None
|
|
561
|
+
first_line = screen.split('\n', 1)[0]
|
|
562
|
+
match = re.search(r'(看板|系列)《([^》]+)》', first_line)
|
|
563
|
+
if match is None or match.group(2).lower() != board.lower():
|
|
564
|
+
return None
|
|
565
|
+
if match.group(1) == '看板' and is_article_list(api, screen):
|
|
566
|
+
return 'plain'
|
|
567
|
+
if match.group(1) == '系列' and connect_core.TargetUnit(screens.Target.InSearchList).is_match(screen, api.cursor):
|
|
568
|
+
return 'search'
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def forget_current_board(api) -> None:
|
|
573
|
+
# 送出 goto_board 以外的搜尋指令後,畫面上的搜尋結果跟記錄對不起來;
|
|
574
|
+
# 下一次 goto_board 一定要走完整路徑重新進板
|
|
575
|
+
api._current_board = None
|
|
576
|
+
api._current_search = ()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _list_targets() -> list:
|
|
580
|
+
return [
|
|
581
|
+
connect_core.TargetUnit(screens.Target.InBoard, log_level=log.DEBUG, break_detect=True),
|
|
582
|
+
connect_core.TargetUnit(screens.Target.InSearchList, log_level=log.DEBUG, break_detect=True),
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _stay_in_board(api, board: str, want: tuple) -> Optional[str]:
|
|
587
|
+
"""上一個 API 停在同一個看板時,不回主選單、不重新進板,只用 ← 退回需要的列表。
|
|
588
|
+
|
|
589
|
+
回傳 'search' 表示已經在 want 這組條件的搜尋結果裡;'plain' 表示在一般文章列表
|
|
590
|
+
(呼叫端視需要再搜尋);None 表示判斷不了,交給完整路徑 (go_main_menu 從任何畫面都能回主選單)。
|
|
591
|
+
每一步都以畫面確認:標頭的看板名稱、一般列表或搜尋結果。"""
|
|
592
|
+
if getattr(api, '_current_board', None) != board.lower():
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
# 最多: 提示框 → 文章內文 → 搜尋結果 → 一般列表
|
|
596
|
+
for _ in range(4):
|
|
597
|
+
screen = last_screen(api)
|
|
598
|
+
kind = board_list_kind(api, screen, board)
|
|
599
|
+
if kind == 'plain':
|
|
600
|
+
return 'plain'
|
|
601
|
+
if kind == 'search' and want and api._current_search == want:
|
|
602
|
+
return 'search'
|
|
603
|
+
|
|
604
|
+
if kind is None and '任意鍵' in screen.rstrip().rsplit('\n', 1)[-1]:
|
|
605
|
+
# 「請按任意鍵繼續」(查詢文章資訊 Q、發文/回文完成);完整路徑也是先按空白鍵關掉提示框
|
|
606
|
+
key = command.space
|
|
607
|
+
elif kind is None and connect_core.TargetUnit(screens.Target.InPost).is_match(screen, api.cursor):
|
|
608
|
+
# 文章內文 ← 回到點進來的列表
|
|
609
|
+
key = command.left
|
|
610
|
+
elif kind == 'search':
|
|
611
|
+
# 搜尋結果 ← 直接回到一般列表 (多層搜尋也是一次)
|
|
612
|
+
key = command.left
|
|
613
|
+
else:
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
target_list = _list_targets() + [
|
|
617
|
+
connect_core.TargetUnit(screens.Target.InPost, log_level=log.DEBUG, break_detect=True),
|
|
618
|
+
]
|
|
619
|
+
index = api.connect_core.send(key, target_list)
|
|
620
|
+
if index < 0:
|
|
621
|
+
if api.connect_core.last_timeout_was_silent:
|
|
622
|
+
raise exceptions.ConnectionClosed()
|
|
623
|
+
return None
|
|
624
|
+
if kind == 'search':
|
|
625
|
+
api._current_search = ()
|
|
626
|
+
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _search_in_board(api, board: str, search_list: list, want: tuple) -> None:
|
|
631
|
+
# 從一般文章列表送出搜尋條件;搜不到時 PTT 跳出「找不到符合條件的文章」並留在一般列表
|
|
632
|
+
cmd = ''.join(get_search_condition_cmd(data_type.NewIndex.BOARD, search_list))
|
|
633
|
+
target_list = [
|
|
634
|
+
connect_core.TargetUnit(screens.Target.NoSearchResult, log_level=log.DEBUG, break_detect=True),
|
|
635
|
+
connect_core.TargetUnit(screens.Target.InSearchList, log_level=log.DEBUG, break_detect=True),
|
|
636
|
+
]
|
|
637
|
+
index = api.connect_core.send(cmd, target_list)
|
|
638
|
+
if index == 1 and board_list_kind(api, last_screen(api), board) == 'search':
|
|
639
|
+
api._current_search = want
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
if index == 0:
|
|
643
|
+
# 關掉提示框;回到一般列表就留在板內,下一次不用重新進板
|
|
644
|
+
index = api.connect_core.send(command.space, _list_targets())
|
|
645
|
+
if index == 0 and board_list_kind(api, last_screen(api), board) == 'plain':
|
|
646
|
+
api._current_search = ()
|
|
647
|
+
raise exceptions.NoSearchResult()
|
|
648
|
+
|
|
649
|
+
# 提示框之後畫面不明;下一次 goto_board 走完整路徑
|
|
650
|
+
forget_current_board(api)
|
|
651
|
+
if index < 0 and api.connect_core.last_timeout_was_silent:
|
|
652
|
+
raise exceptions.ConnectionClosed()
|
|
653
|
+
raise exceptions.NoSearchResult()
|
|
654
|
+
|
|
655
|
+
# 搜尋後認不得畫面 (跟改版前一樣:完全沒回應是斷線,其他當成進不了看板)
|
|
656
|
+
forget_current_board(api)
|
|
657
|
+
if index < 0 and api.connect_core.last_timeout_was_silent:
|
|
658
|
+
raise exceptions.ConnectionClosed()
|
|
659
|
+
raise exceptions.NoSuchBoard(api.config, board)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def goto_board(api, board: str, refresh: bool = False, end: bool = False,
|
|
663
|
+
search_list: Optional[list] = None) -> None:
|
|
664
|
+
"""進到 board 的文章列表;給 search_list 時進到該條件的搜尋結果 (搜不到丟 NoSearchResult)。
|
|
665
|
+
refresh=True 保證最後一個畫面是剛重畫的一般列表 (不跟 search_list 一起用)。
|
|
666
|
+
|
|
667
|
+
上一個 API 已經停在同一個看板時不重新進板 (見 _stay_in_board)。"""
|
|
668
|
+
want = search_key(search_list)
|
|
669
|
+
state = _stay_in_board(api, board, want)
|
|
670
|
+
if state == 'plain' and refresh:
|
|
671
|
+
# 呼叫端要讀最新畫面 (get_board_info 的人氣):不重新進板,重畫一次
|
|
672
|
+
index = api.connect_core.send(command.refresh, _list_targets(), refresh=False)
|
|
673
|
+
if index < 0 and api.connect_core.last_timeout_was_silent:
|
|
674
|
+
raise exceptions.ConnectionClosed()
|
|
675
|
+
if index != 0 or board_list_kind(api, last_screen(api), board) != 'plain':
|
|
676
|
+
state = None
|
|
677
|
+
if state is not None:
|
|
678
|
+
if want and state == 'plain':
|
|
679
|
+
_search_in_board(api, board, search_list, want)
|
|
680
|
+
if end:
|
|
681
|
+
_goto_board_end(api)
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
forget_current_board(api)
|
|
685
|
+
|
|
551
686
|
cmd_list = []
|
|
552
687
|
cmd_list.append(command.go_main_menu)
|
|
553
688
|
cmd_list.append('qs')
|
|
@@ -591,18 +726,28 @@ def goto_board(api, board: str, refresh: bool = False, end: bool = False) -> Non
|
|
|
591
726
|
if index == 4:
|
|
592
727
|
raise exceptions.NoSuchBoard(api.config, board)
|
|
593
728
|
|
|
729
|
+
if index in (2, 3):
|
|
730
|
+
api._current_board = board.lower()
|
|
731
|
+
api._current_search = ()
|
|
732
|
+
if want:
|
|
733
|
+
_search_in_board(api, board, search_list, want)
|
|
734
|
+
|
|
594
735
|
if end:
|
|
595
|
-
|
|
596
|
-
cmd_list.append('1')
|
|
597
|
-
cmd_list.append(command.enter)
|
|
598
|
-
cmd_list.append('$')
|
|
599
|
-
cmd = ''.join(cmd_list)
|
|
736
|
+
_goto_board_end(api)
|
|
600
737
|
|
|
601
|
-
target_list = [
|
|
602
|
-
connect_core.TargetUnit(screens.Target.InBoard, log_level=log.DEBUG, break_detect=True),
|
|
603
|
-
]
|
|
604
738
|
|
|
605
|
-
|
|
739
|
+
def _goto_board_end(api) -> None:
|
|
740
|
+
cmd_list = []
|
|
741
|
+
cmd_list.append('1')
|
|
742
|
+
cmd_list.append(command.enter)
|
|
743
|
+
cmd_list.append('$')
|
|
744
|
+
cmd = ''.join(cmd_list)
|
|
745
|
+
|
|
746
|
+
target_list = [
|
|
747
|
+
connect_core.TargetUnit(screens.Target.InBoard, log_level=log.DEBUG, break_detect=True),
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
api.connect_core.send(cmd, target_list)
|
|
606
751
|
|
|
607
752
|
|
|
608
753
|
def one_thread(api):
|
|
@@ -97,6 +97,16 @@ class Target:
|
|
|
97
97
|
['相關主題', '(b)進板'],
|
|
98
98
|
]
|
|
99
99
|
|
|
100
|
+
# 搜尋結果列表: 標頭「系列《Python》」(pttbbs 一直以來的寫法);PTT1 新版頁尾是 '系列文章 (^P)發表 ...',
|
|
101
|
+
# 頁尾各站不一定相同,所以只認標頭跟看板說明列
|
|
102
|
+
InSearchList = [
|
|
103
|
+
'系列《',
|
|
104
|
+
['看板資訊/設定', '[i]看板設定'],
|
|
105
|
+
['相關主題', '(b)進板'],
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
NoSearchResult = '找不到符合條件的文章'
|
|
109
|
+
|
|
100
110
|
InBoardWithCursor = [
|
|
101
111
|
'【',
|
|
102
112
|
['看板資訊/設定', '[i]看板設定'],
|
|
@@ -346,7 +356,8 @@ xy_pattern_h = re.compile(r'^=ESC=\[[\d]+;[\d]+H')
|
|
|
346
356
|
xy_pattern_s = re.compile(r'^=ESC=\[[\d]+;[\d]+s')
|
|
347
357
|
# 依 ECMA-48 剝掉引擎不處理的 CSI (顏色、ESC[?2026h、ESC[6n…, PttCurrent M.1789835163.A.633),
|
|
348
358
|
# 以及被下一個 ESC 打斷的孤立 ESC (主選單動畫尾端的 '\x1b\x1b[?2026h'); 留著會讓引擎凍住畫面
|
|
349
|
-
|
|
359
|
+
# OSC (ESC ] ... BEL 或 ESC \\) 也一併剝掉, 例如內文網址的 OSC 8 超連結 '\x1b]8;;URL\x1b\\'
|
|
360
|
+
_color_sgr = re.compile(r'\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B(?=\x1B)|\x1B\[(?!(?:\d+;\d+)?[Hs]|K|2J)[0-?]*[ -/]*[@-~]')
|
|
350
361
|
|
|
351
362
|
|
|
352
363
|
def _preprocess(data: str) -> str:
|
|
@@ -531,6 +542,8 @@ def _escape_complete(seq: str) -> bool:
|
|
|
531
542
|
engine will bail on it, same as the batch parser)."""
|
|
532
543
|
if len(seq) < 2:
|
|
533
544
|
return False # lone ESC — wait for more
|
|
545
|
+
if seq[1] == ']':
|
|
546
|
+
return '\x07' in seq or '\x1b\\' in seq # OSC 要等到 BEL 或 ESC \\ 結尾
|
|
534
547
|
if seq[1] != '[':
|
|
535
548
|
return True # ESC + non-'[': engine bails on it
|
|
536
549
|
for ch in seq[2:]:
|
|
@@ -571,6 +584,9 @@ class IncrementalScreen(_VT100Engine):
|
|
|
571
584
|
while cut > 0 and s[cut - 1] in ' \x08':
|
|
572
585
|
cut -= 1
|
|
573
586
|
esc = s.rfind('\x1b')
|
|
587
|
+
osc = s.rfind('\x1b]')
|
|
588
|
+
if osc != -1 and not _escape_complete(s[osc:]):
|
|
589
|
+
esc = osc # OSC 的 ESC \\ 結尾可能還沒到, 從 OSC 開頭整段留著
|
|
574
590
|
if esc != -1 and not _escape_complete(s[esc:]):
|
|
575
591
|
while esc > 0 and s[esc - 1] == '\x1b':
|
|
576
592
|
esc -= 1 # 孤立 ESC 要跟後面的 ESC 一起送, _preprocess 才認得
|
|
@@ -118,6 +118,16 @@ class TestLineLayouts:
|
|
|
118
118
|
assert posts[0][data_type.PostField.push_number] == ''
|
|
119
119
|
assert posts[0][data_type.PostField.list_date] == '4/17'
|
|
120
120
|
|
|
121
|
+
def test_full_width_push_count(self):
|
|
122
|
+
# 「爆」是全形字,終端機上佔 2 格;用字元位置切的話後面每一欄都會左移一格
|
|
123
|
+
cursor = '> 9446 +爆10/15 someone □ [問安] 1015'
|
|
124
|
+
api = _build_api(_build_screen(cursor, []), newest=9446)
|
|
125
|
+
posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
|
|
126
|
+
assert posts[0][data_type.PostField.push_number] == '爆'
|
|
127
|
+
assert posts[0][data_type.PostField.list_date] == '10/15'
|
|
128
|
+
assert posts[0][data_type.PostField.author] == "someone"
|
|
129
|
+
assert posts[0][data_type.PostField.title].strip() == '□ [問安] 1015'
|
|
130
|
+
|
|
121
131
|
def test_reply_marker_R_in_title(self):
|
|
122
132
|
# Title section includes the kind marker ('R:', '□', '轉', '鎖').
|
|
123
133
|
cursor = '> 9440 4 1/15 ddavid R: [問題] @property 真正的運用是啥'
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""No-network tests for goto_board's same-board shortcut.
|
|
2
|
+
|
|
3
|
+
When the previous API left PyPtt on the same board (article list, search
|
|
4
|
+
result, or an article opened from either), goto_board must not go back to the
|
|
5
|
+
main menu and re-enter the board; it only sends ← and confirms the list header
|
|
6
|
+
names the board. A search result is reused when the search conditions are the
|
|
7
|
+
same. Anything it cannot confirm takes the full path.
|
|
8
|
+
"""
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import PyPtt
|
|
14
|
+
from PyPtt import _api_util
|
|
15
|
+
from PyPtt import command
|
|
16
|
+
from PyPtt import config
|
|
17
|
+
from PyPtt.data_type import SearchType
|
|
18
|
+
from tests.test_board_setting_flow import ARTICLE_LIST, FakeCore
|
|
19
|
+
|
|
20
|
+
LEFT, ENTER, CTRL_C = command.left, command.enter, command.ctrl_c
|
|
21
|
+
FULL_PATH = command.go_main_menu + 'qsgive' + ENTER + CTRL_C * 5
|
|
22
|
+
|
|
23
|
+
POST = ('作者 someone (nick) 看板 give\n'
|
|
24
|
+
' 標題 [贈送] 測試文章\n 時間 Sun Oct 19 08:00:00 2026\n\nbody\n' + '\n' * 17 +
|
|
25
|
+
' 瀏覽 第 1/1 頁 (100%) 目前顯示: 第 01~05 行 (y)回應(X%)推文(h)說明(←)離開 ')
|
|
26
|
+
QUERY_POPUP = (ARTICLE_LIST.rsplit('\n', 1)[0] +
|
|
27
|
+
'\n ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 請按任意鍵繼續 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ')
|
|
28
|
+
OTHER_LIST = ARTICLE_LIST.replace('看板《give》', '看板《Test》')
|
|
29
|
+
MAIL_LIST = '''【郵件選單】 信箱《SomeBM》
|
|
30
|
+
編號 日 期 作 者 信 件 標 題
|
|
31
|
+
> 1 + 10/18 someone 測試
|
|
32
|
+
(y)回信 (x)站內轉寄 (d/D)刪信 (^P)寄發新信 (←)離開 (h)說明 '''
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SEARCH_LIST = (ARTICLE_LIST.replace('看板《give》', '系列《give》')
|
|
36
|
+
.replace(' 文章列表 (^P)', ' 系列文章 (^P)'))
|
|
37
|
+
NO_RESULT = ARTICLE_LIST.rsplit('\n', 1)[0] + '\n ◆ 找不到符合條件的文章。 [按任意鍵繼續] '
|
|
38
|
+
KEYWORD = [(SearchType.KEYWORD, '贈送')]
|
|
39
|
+
AUTHOR = [(SearchType.AUTHOR, 'someone')]
|
|
40
|
+
KEYWORD_CMD = '/贈送' + ENTER
|
|
41
|
+
AUTHOR_CMD = 'asomeone' + ENTER
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_api(screen_list, start, current_board='give', current_search=()):
|
|
45
|
+
return SimpleNamespace(
|
|
46
|
+
connect_core=FakeCore(screen_list, start),
|
|
47
|
+
config=config.Config(),
|
|
48
|
+
_is_login=True,
|
|
49
|
+
cursor='>',
|
|
50
|
+
_goto_board_list={'give'},
|
|
51
|
+
_current_board=current_board,
|
|
52
|
+
_current_search=tuple(current_search),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestSameBoardShortcut:
|
|
57
|
+
def test_article_list_of_same_board_sends_nothing(self):
|
|
58
|
+
api = make_api([], start=ARTICLE_LIST)
|
|
59
|
+
_api_util.goto_board(api, 'give')
|
|
60
|
+
assert api.connect_core.keys == []
|
|
61
|
+
|
|
62
|
+
def test_board_name_is_case_insensitive(self):
|
|
63
|
+
api = make_api([], start=ARTICLE_LIST)
|
|
64
|
+
_api_util.goto_board(api, 'GIVE')
|
|
65
|
+
assert api.connect_core.keys == []
|
|
66
|
+
|
|
67
|
+
def test_article_of_same_board_sends_one_left(self):
|
|
68
|
+
api = make_api([ARTICLE_LIST], start=POST)
|
|
69
|
+
_api_util.goto_board(api, 'give')
|
|
70
|
+
assert api.connect_core.keys == [LEFT]
|
|
71
|
+
assert api._current_board == 'give'
|
|
72
|
+
|
|
73
|
+
def test_refresh_redraws_instead_of_entering(self):
|
|
74
|
+
# get_board_info 要最新的人氣:重畫一次,不重新進板
|
|
75
|
+
api = make_api([ARTICLE_LIST], start=ARTICLE_LIST)
|
|
76
|
+
_api_util.goto_board(api, 'give', refresh=True)
|
|
77
|
+
assert api.connect_core.keys == [command.refresh]
|
|
78
|
+
|
|
79
|
+
def test_refresh_from_article(self):
|
|
80
|
+
api = make_api([ARTICLE_LIST, ARTICLE_LIST], start=POST)
|
|
81
|
+
_api_util.goto_board(api, 'give', refresh=True)
|
|
82
|
+
assert api.connect_core.keys == [LEFT, command.refresh]
|
|
83
|
+
|
|
84
|
+
def test_query_popup_is_dismissed(self):
|
|
85
|
+
# get_post(query=True) 停在「請按任意鍵繼續」
|
|
86
|
+
api = make_api([ARTICLE_LIST], start=QUERY_POPUP)
|
|
87
|
+
_api_util.goto_board(api, 'give')
|
|
88
|
+
assert api.connect_core.keys == [' ']
|
|
89
|
+
|
|
90
|
+
def test_popup_then_article(self):
|
|
91
|
+
api = make_api([POST, ARTICLE_LIST], start=QUERY_POPUP)
|
|
92
|
+
_api_util.goto_board(api, 'give')
|
|
93
|
+
assert api.connect_core.keys == [' ', LEFT]
|
|
94
|
+
|
|
95
|
+
def test_end_from_shortcut_jumps_to_bottom(self):
|
|
96
|
+
api = make_api([ARTICLE_LIST, ARTICLE_LIST], start=POST)
|
|
97
|
+
_api_util.goto_board(api, 'give', end=True)
|
|
98
|
+
assert api.connect_core.keys == [LEFT, '1' + ENTER + '$']
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestFallsBackToFullPath:
|
|
102
|
+
def test_different_board(self):
|
|
103
|
+
api = make_api([OTHER_LIST, ARTICLE_LIST], start=OTHER_LIST, current_board='test')
|
|
104
|
+
_api_util.goto_board(api, 'give')
|
|
105
|
+
assert api.connect_core.keys == [FULL_PATH]
|
|
106
|
+
assert api._current_board == 'give'
|
|
107
|
+
|
|
108
|
+
def test_unknown_current_board(self):
|
|
109
|
+
# 第一次進板,或搜尋過後 (forget_current_board)
|
|
110
|
+
api = make_api([ARTICLE_LIST], start=ARTICLE_LIST, current_board=None)
|
|
111
|
+
_api_util.goto_board(api, 'give')
|
|
112
|
+
assert api.connect_core.keys == [FULL_PATH]
|
|
113
|
+
|
|
114
|
+
def test_header_names_another_board(self):
|
|
115
|
+
# 追蹤的看板跟畫面不一致 (例如其他 API 換過地方) 時,以畫面為準
|
|
116
|
+
api = make_api([ARTICLE_LIST], start=OTHER_LIST)
|
|
117
|
+
_api_util.goto_board(api, 'give')
|
|
118
|
+
assert api.connect_core.keys == [FULL_PATH]
|
|
119
|
+
|
|
120
|
+
def test_left_from_article_lands_elsewhere(self):
|
|
121
|
+
# 停在信件內文:← 回到信箱,不是看板
|
|
122
|
+
api = make_api([MAIL_LIST, ARTICLE_LIST], start=POST)
|
|
123
|
+
_api_util.goto_board(api, 'give')
|
|
124
|
+
assert api.connect_core.keys == [LEFT, FULL_PATH]
|
|
125
|
+
|
|
126
|
+
def test_left_times_out_with_data(self):
|
|
127
|
+
api = make_api(['half drawn', ARTICLE_LIST], start=POST)
|
|
128
|
+
_api_util.goto_board(api, 'give')
|
|
129
|
+
assert api.connect_core.keys == [LEFT, FULL_PATH]
|
|
130
|
+
|
|
131
|
+
def test_main_menu_screen(self):
|
|
132
|
+
api = make_api([ARTICLE_LIST], start='【主功能表】 批踢踢實業坊')
|
|
133
|
+
_api_util.goto_board(api, 'give')
|
|
134
|
+
assert api.connect_core.keys == [FULL_PATH]
|
|
135
|
+
|
|
136
|
+
def test_refresh_redraw_not_list_takes_full_path(self):
|
|
137
|
+
api = make_api(['garbage', ARTICLE_LIST], start=ARTICLE_LIST)
|
|
138
|
+
_api_util.goto_board(api, 'give', refresh=True)
|
|
139
|
+
assert api.connect_core.keys == [command.refresh, FULL_PATH]
|
|
140
|
+
|
|
141
|
+
def test_popup_over_other_screen_takes_full_path(self):
|
|
142
|
+
# 提示框關掉後不是這個看板 (例如查詢使用者之後)
|
|
143
|
+
api = make_api(['【主功能表】 看板《give》\n (h)說明', ARTICLE_LIST], start=QUERY_POPUP)
|
|
144
|
+
_api_util.goto_board(api, 'give')
|
|
145
|
+
assert api.connect_core.keys == [' ', FULL_PATH]
|
|
146
|
+
|
|
147
|
+
def test_silent_timeout_after_left_raises(self):
|
|
148
|
+
api = make_api([None], start=POST)
|
|
149
|
+
with pytest.raises(PyPtt.exceptions.ConnectionClosed):
|
|
150
|
+
_api_util.goto_board(api, 'give')
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestSearch:
|
|
154
|
+
def test_same_search_list_sends_nothing(self):
|
|
155
|
+
api = make_api([], start=SEARCH_LIST, current_search=KEYWORD)
|
|
156
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
157
|
+
assert api.connect_core.keys == []
|
|
158
|
+
|
|
159
|
+
def test_article_from_same_search_sends_one_left(self):
|
|
160
|
+
api = make_api([SEARCH_LIST], start=POST, current_search=KEYWORD)
|
|
161
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
162
|
+
assert api.connect_core.keys == [LEFT]
|
|
163
|
+
assert api._current_search == tuple(KEYWORD)
|
|
164
|
+
|
|
165
|
+
def test_plain_list_wanted_from_search_list(self):
|
|
166
|
+
# 搜尋結果 ← 一次就回到一般列表
|
|
167
|
+
api = make_api([ARTICLE_LIST], start=SEARCH_LIST, current_search=KEYWORD)
|
|
168
|
+
_api_util.goto_board(api, 'give')
|
|
169
|
+
assert api.connect_core.keys == [LEFT]
|
|
170
|
+
assert api._current_search == ()
|
|
171
|
+
|
|
172
|
+
def test_plain_list_wanted_from_article_in_search(self):
|
|
173
|
+
api = make_api([SEARCH_LIST, ARTICLE_LIST], start=POST, current_search=KEYWORD)
|
|
174
|
+
_api_util.goto_board(api, 'give')
|
|
175
|
+
assert api.connect_core.keys == [LEFT, LEFT]
|
|
176
|
+
|
|
177
|
+
def test_other_search_goes_back_and_searches_again(self):
|
|
178
|
+
api = make_api([ARTICLE_LIST, SEARCH_LIST], start=SEARCH_LIST, current_search=KEYWORD)
|
|
179
|
+
_api_util.goto_board(api, 'give', search_list=AUTHOR)
|
|
180
|
+
assert api.connect_core.keys == [LEFT, AUTHOR_CMD]
|
|
181
|
+
assert api._current_search == tuple(AUTHOR)
|
|
182
|
+
|
|
183
|
+
def test_search_from_plain_list(self):
|
|
184
|
+
api = make_api([SEARCH_LIST], start=ARTICLE_LIST)
|
|
185
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD + AUTHOR)
|
|
186
|
+
assert api.connect_core.keys == [KEYWORD_CMD + AUTHOR_CMD]
|
|
187
|
+
assert api._current_search == tuple(KEYWORD + AUTHOR)
|
|
188
|
+
|
|
189
|
+
def test_full_path_then_search(self):
|
|
190
|
+
api = make_api([ARTICLE_LIST, SEARCH_LIST], start=OTHER_LIST, current_board='test')
|
|
191
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
192
|
+
assert api.connect_core.keys == [FULL_PATH, KEYWORD_CMD]
|
|
193
|
+
assert (api._current_board, api._current_search) == ('give', tuple(KEYWORD))
|
|
194
|
+
|
|
195
|
+
def test_no_result_dismisses_and_stays_in_board(self):
|
|
196
|
+
api = make_api([NO_RESULT, ARTICLE_LIST], start=ARTICLE_LIST)
|
|
197
|
+
with pytest.raises(PyPtt.exceptions.NoSearchResult):
|
|
198
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
199
|
+
assert api.connect_core.keys == [KEYWORD_CMD, ' ']
|
|
200
|
+
assert (api._current_board, api._current_search) == ('give', ())
|
|
201
|
+
|
|
202
|
+
def test_unknown_screen_after_search_is_no_such_board(self):
|
|
203
|
+
api = make_api(['garbage'], start=ARTICLE_LIST)
|
|
204
|
+
with pytest.raises(PyPtt.exceptions.NoSuchBoard):
|
|
205
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
206
|
+
assert api._current_board is None
|
|
207
|
+
|
|
208
|
+
def test_no_result_then_unknown_screen_forgets_board(self):
|
|
209
|
+
api = make_api([NO_RESULT, 'garbage'], start=ARTICLE_LIST)
|
|
210
|
+
with pytest.raises(PyPtt.exceptions.NoSearchResult):
|
|
211
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
212
|
+
assert api._current_board is None
|
|
213
|
+
|
|
214
|
+
def test_search_list_without_record_is_not_reused(self):
|
|
215
|
+
# 畫面在搜尋結果,但記錄的條件不同 (或沒有記錄):退回一般列表重搜
|
|
216
|
+
api = make_api([ARTICLE_LIST, SEARCH_LIST], start=SEARCH_LIST, current_search=())
|
|
217
|
+
_api_util.goto_board(api, 'give', search_list=KEYWORD)
|
|
218
|
+
assert api.connect_core.keys == [LEFT, KEYWORD_CMD]
|
|
219
|
+
|
|
220
|
+
def test_search_list_of_other_board(self):
|
|
221
|
+
other = SEARCH_LIST.replace('系列《give》', '系列《Test》')
|
|
222
|
+
api = make_api([ARTICLE_LIST], start=other, current_search=KEYWORD)
|
|
223
|
+
_api_util.goto_board(api, 'give', search_list=None)
|
|
224
|
+
assert api.connect_core.keys == [FULL_PATH]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_forget_current_board():
|
|
228
|
+
api = make_api([], start=ARTICLE_LIST, current_search=KEYWORD)
|
|
229
|
+
_api_util.forget_current_board(api)
|
|
230
|
+
assert api._current_board is None
|
|
231
|
+
assert api._current_search == ()
|
|
@@ -124,9 +124,9 @@ def test_newest_index_noisy_timeout_still_raises_no_such_board():
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
def test_newest_index_search_round_silent_timeout_raises_connection_closed():
|
|
127
|
-
"""The
|
|
128
|
-
|
|
129
|
-
api = FakeApi(FakeConnectCore([(2, False), (
|
|
127
|
+
"""The search send (goto_board's _search_in_board) has its own flag
|
|
128
|
+
check; the goto_board send before it must not mask it."""
|
|
129
|
+
api = FakeApi(FakeConnectCore([(2, False), (-1, True)],
|
|
130
130
|
screens=(BOARD_SCREEN,)))
|
|
131
131
|
with pytest.raises(exceptions.ConnectionClosed):
|
|
132
132
|
_api_get_newest_index.get_newest_index(
|
|
@@ -135,7 +135,7 @@ def test_newest_index_search_round_silent_timeout_raises_connection_closed():
|
|
|
135
135
|
|
|
136
136
|
|
|
137
137
|
def test_newest_index_search_round_noisy_timeout_raises_no_such_board():
|
|
138
|
-
api = FakeApi(FakeConnectCore([(2, False), (
|
|
138
|
+
api = FakeApi(FakeConnectCore([(2, False), (-1, False)],
|
|
139
139
|
screens=(BOARD_SCREEN,)))
|
|
140
140
|
with pytest.raises(exceptions.NoSuchBoard):
|
|
141
141
|
_api_get_newest_index.get_newest_index(
|
|
@@ -54,6 +54,8 @@ CRAFTED = [
|
|
|
54
54
|
b' \x08\x08\xe2\x97\x8e tail', # ◎ via reservation pair
|
|
55
55
|
b'\x1b[10;1H \x08\x08\xe2\x80\xbb\x1b[10;3H \xe7\x99\xbc\xe4\xbf\xa1\xe7\xab\x99: x',
|
|
56
56
|
b'\x1b[6nHELLO', # unknown escape -> bail
|
|
57
|
+
b'a\x1b]8;;https://x.y/z\x1b\\link\x1b]8;;\x1b\\b', # OSC 8 hyperlink (ST)
|
|
58
|
+
b'a\x1b]8;;https://x.y/z\x07link\x1b]8;;\x07b', # OSC 8 hyperlink (BEL)
|
|
57
59
|
b'line1\r\nline2', # CRLF
|
|
58
60
|
b'helloworld\x1b[1;2HXY', # replace mode
|
|
59
61
|
b'\x1b[100;1HX', # row clamp
|
|
@@ -289,7 +289,8 @@ def _newest_api(screen_list):
|
|
|
289
289
|
|
|
290
290
|
|
|
291
291
|
def test_newest_index_no_search_result(no_board_nav):
|
|
292
|
-
|
|
292
|
+
# goto_board (含搜尋) 被換掉;搜尋後的 '1\r$' 落在文章裡就是沒有結果
|
|
293
|
+
api = _newest_api([_fx('newest_index_in_post.txt')])
|
|
293
294
|
with pytest.raises(exceptions.NoSearchResult):
|
|
294
295
|
_api_get_newest_index.get_newest_index(api, data_type.NewIndex.BOARD, board='Test',
|
|
295
296
|
search_type=data_type.SearchType.AUTHOR,
|
|
@@ -297,7 +298,7 @@ def test_newest_index_no_search_result(no_board_nav):
|
|
|
297
298
|
|
|
298
299
|
|
|
299
300
|
def test_newest_index_no_search_result_on_animation_post(no_board_nav):
|
|
300
|
-
api = _newest_api([
|
|
301
|
+
api = _newest_api([screens.Target.Animation[0]])
|
|
301
302
|
with pytest.raises(exceptions.NoSearchResult):
|
|
302
303
|
_api_get_newest_index.get_newest_index(api, data_type.NewIndex.BOARD, board='Test',
|
|
303
304
|
search_type=data_type.SearchType.AUTHOR,
|
|
@@ -481,3 +481,26 @@ def test_stray_esc_split_across_chunks():
|
|
|
481
481
|
s.feed(data[:cut])
|
|
482
482
|
s.feed(data[cut:])
|
|
483
483
|
assert s.screen == VT100Parser(data, 'utf-8').screen, cut
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# 內文網址被包成 OSC 8 超連結 (ESC ] 8 ; ; URL ST 文字 ESC ] 8 ; ; ST); 沒被吃掉時引擎凍住, get_content 讀不到頁尾
|
|
487
|
+
OSC8_POST = (b'\x1b[2J\x1b[3;1H\xe7\xb6\xb2\xe5\x9d\x80 \x1b]8;;https://example.com/a?b=1\x1b\\https://example.com/a?b=1'
|
|
488
|
+
b'\x1b]8;;\x1b\\ end\x1b[4;1H\x1b]8;;https://ptt.cc\x07ptt\x1b]8;;\x07\x1b[24;1Hfooter')
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_osc8_hyperlink_is_stripped():
|
|
492
|
+
lines = parse(OSC8_POST)
|
|
493
|
+
assert lines[2] == '網址 https://example.com/a?b=1 end'
|
|
494
|
+
assert lines[3] == 'ptt'
|
|
495
|
+
assert lines[23] == 'footer'
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_osc8_hyperlink_split_across_chunks():
|
|
499
|
+
from PyPtt.screens import IncrementalScreen
|
|
500
|
+
expected = VT100Parser(OSC8_POST, 'utf-8').screen
|
|
501
|
+
for cut in range(1, len(OSC8_POST)):
|
|
502
|
+
s = IncrementalScreen('utf-8')
|
|
503
|
+
s.feed(OSC8_POST[:cut])
|
|
504
|
+
s.feed(OSC8_POST[cut:])
|
|
505
|
+
assert not s._bailed, cut
|
|
506
|
+
assert s.screen == expected, cut
|
|
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
|
|
File without changes
|