PyPtt 2.3.15__tar.gz → 2.3.17__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 (110) hide show
  1. {pyptt-2.3.15 → pyptt-2.3.17}/PKG-INFO +1 -1
  2. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/PTT.py +2 -0
  3. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_newest_index.py +11 -38
  5. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_post.py +6 -9
  6. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_mark_post.py +6 -16
  7. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_util.py +155 -10
  8. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/connect_core.py +16 -16
  9. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/screens.py +10 -0
  10. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt.egg-info/PKG-INFO +1 -1
  11. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt.egg-info/SOURCES.txt +3 -1
  12. pyptt-2.3.17/tests/test_goto_board_same_board.py +231 -0
  13. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_half_dead_connection.py +4 -4
  14. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_new_screen_fixes.py +3 -2
  15. pyptt-2.3.17/tests/test_websocket_endpoint.py +39 -0
  16. {pyptt-2.3.15 → pyptt-2.3.17}/LICENSE +0 -0
  17. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_bucket.py +0 -0
  18. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_call_status.py +0 -0
  19. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_change_pw.py +0 -0
  20. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_comment.py +0 -0
  21. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_del_post.py +0 -0
  22. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_board_info.py +0 -0
  23. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_board_list.py +0 -0
  24. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_bottom_post_list.py +0 -0
  25. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_favourite_board.py +0 -0
  26. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_post_index.py +0 -0
  27. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_post_list.py +0 -0
  28. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_time.py +0 -0
  29. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_user.py +0 -0
  30. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_get_waterball.py +0 -0
  31. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_give_money.py +0 -0
  32. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_has_new_mail.py +0 -0
  33. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_loginout.py +0 -0
  34. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_mail.py +0 -0
  35. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_post.py +0 -0
  36. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_reply_post.py +0 -0
  37. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_search_user.py +0 -0
  38. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_set_board_title.py +0 -0
  39. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_api_set_signature_file.py +0 -0
  40. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_uao/LICENSE +0 -0
  41. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_uao/__init__.py +0 -0
  42. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_uao/b2u.py +0 -0
  43. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/_uao/u2b.py +0 -0
  44. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/api_server.py +0 -0
  45. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/check_value.py +0 -0
  46. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/command.py +0 -0
  47. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/config.py +0 -0
  48. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/data_type.py +0 -0
  49. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/exceptions.py +0 -0
  50. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/i18n.py +0 -0
  51. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/lang_en_US.py +0 -0
  52. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/lang_zh_TW.py +0 -0
  53. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/lib_util.py +0 -0
  54. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/log.py +0 -0
  55. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/service.py +0 -0
  56. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt/ssl_config.py +0 -0
  57. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt.egg-info/dependency_links.txt +0 -0
  58. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt.egg-info/requires.txt +0 -0
  59. {pyptt-2.3.15 → pyptt-2.3.17}/PyPtt.egg-info/top_level.txt +0 -0
  60. {pyptt-2.3.15 → pyptt-2.3.17}/README.md +0 -0
  61. {pyptt-2.3.15 → pyptt-2.3.17}/pyproject.toml +0 -0
  62. {pyptt-2.3.15 → pyptt-2.3.17}/setup.cfg +0 -0
  63. {pyptt-2.3.15 → pyptt-2.3.17}/setup.py +0 -0
  64. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_backup_effective.py +0 -0
  65. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_backup_param.py +0 -0
  66. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_board_setting_flow.py +0 -0
  67. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_bucket.py +0 -0
  68. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_change_pw.py +0 -0
  69. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_comment.py +0 -0
  70. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_connect_core_timeout.py +0 -0
  71. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_del_post.py +0 -0
  72. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_del_post_unit.py +0 -0
  73. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_exceptions.py +0 -0
  74. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_fast_post.py +0 -0
  75. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_board_info.py +0 -0
  76. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_board_info_parser.py +0 -0
  77. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_board_list.py +0 -0
  78. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_bottom_post_list.py +0 -0
  79. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_content_formula.py +0 -0
  80. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_favourite_boards.py +0 -0
  81. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_mail.py +0 -0
  82. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_newest_index.py +0 -0
  83. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_post.py +0 -0
  84. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_post_list.py +0 -0
  85. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_post_list_parser.py +0 -0
  86. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_post_parser.py +0 -0
  87. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_time.py +0 -0
  88. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_user.py +0 -0
  89. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_get_waterball.py +0 -0
  90. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_give_money.py +0 -0
  91. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_i18n.py +0 -0
  92. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_incremental_parity.py +0 -0
  93. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_init.py +0 -0
  94. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_logger.py +0 -0
  95. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_login_logout.py +0 -0
  96. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_mail_send_and_del.py +0 -0
  97. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_main_menu_status_bar.py +0 -0
  98. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_mark_post.py +0 -0
  99. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_parse_query_post.py +0 -0
  100. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_post.py +0 -0
  101. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_post_anonymous.py +0 -0
  102. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_pyte_parity.py +0 -0
  103. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_reply_post.py +0 -0
  104. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_search_user.py +0 -0
  105. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_service.py +0 -0
  106. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_set_board_title.py +0 -0
  107. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_set_signature_file.py +0 -0
  108. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_status_line.py +0 -0
  109. {pyptt-2.3.15 → pyptt-2.3.17}/tests/test_uao_codec.py +0 -0
  110. {pyptt-2.3.15 → pyptt-2.3.17}/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.15
3
+ Version: 2.3.17
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)
@@ -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
@@ -1,4 +1,4 @@
1
- __version__ = '2.3.15'
1
+ __version__ = '2.3.17'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -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
- _api_util.goto_board(api, board)
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
- normal_newest_index = _get_newest_index(api)
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, search_cmd)
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
- search_cmd_list: Optional[list[str]] = None) -> Dict:
98
+ search_list: Optional[list] = None) -> Dict:
101
99
  _api_util.check_board(api, board)
102
- _api_util.goto_board(api, board)
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 == 1:
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
@@ -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
- _api_util.goto_board(api, board)
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 goto_board(api, board: str, refresh: bool = False, end: bool = False) -> None:
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
- cmd_list = []
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
- api.connect_core.send(cmd, target_list)
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):
@@ -179,6 +179,21 @@ class API(object):
179
179
  asyncio.set_event_loop(self._loop)
180
180
  return self._loop
181
181
 
182
+ def _websocket_endpoint(self) -> tuple[str, str]:
183
+ # PTT1 started answering wss://ws.ptt.cc/bbs/ (trailing slash) with HTTP 404 on
184
+ # 2026-11-06; term.ptt.cc connects to /bbs, so use the slash-less path everywhere
185
+ if self.config.host == data_type.HOST.PTT1:
186
+ return 'wss://ws.ptt.cc/bbs', 'https://term.ptt.cc'
187
+ if self.config.host == data_type.HOST.PTT2:
188
+ return 'wss://ws.ptt2.cc/bbs', 'https://term.ptt2.cc'
189
+ if self.config.host == data_type.HOST.LOCALHOST:
190
+ # ponytail: local pttbbs docker image (bbsdocker/imageptt) serves plain ws
191
+ # (no TLS) on 48763; config.port defaults to 23 (telnet default) so treat
192
+ # that as "not set" and fall back to imageptt's default port
193
+ port = self.config.port if self.config.port != 23 else 48763
194
+ return f'ws://localhost:{port}/bbs', 'http://localhost'
195
+ return f'wss://{self.config.host}', 'https://term.ptt.cc'
196
+
182
197
  def connect(self) -> None:
183
198
  def _wait():
184
199
  for i in range(self.config.retry_wait_time):
@@ -198,22 +213,7 @@ class API(object):
198
213
 
199
214
  self.current_encoding = 'big5uao'
200
215
 
201
- if self.config.host == data_type.HOST.PTT1:
202
- websocket_host = 'wss://ws.ptt.cc/bbs/'
203
- websocket_origin = 'https://term.ptt.cc'
204
- elif self.config.host == data_type.HOST.PTT2:
205
- websocket_host = 'wss://ws.ptt2.cc/bbs/'
206
- websocket_origin = 'https://term.ptt2.cc'
207
- elif self.config.host == data_type.HOST.LOCALHOST:
208
- # ponytail: local pttbbs docker image (bbsdocker/imageptt) serves plain ws
209
- # (no TLS) on 48763; config.port defaults to 23 (telnet default) so treat
210
- # that as "not set" and fall back to imageptt's default port
211
- port = self.config.port if self.config.port != 23 else 48763
212
- websocket_host = f'ws://localhost:{port}/bbs'
213
- websocket_origin = 'http://localhost'
214
- else:
215
- websocket_host = f'wss://{self.config.host}'
216
- websocket_origin = 'https://term.ptt.cc'
216
+ websocket_host, websocket_origin = self._websocket_endpoint()
217
217
 
218
218
  self._pending = []
219
219
  connect_success = False
@@ -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]看板設定'],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.3.15
3
+ Version: 2.3.17
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)
@@ -82,6 +82,7 @@ tests/test_get_time.py
82
82
  tests/test_get_user.py
83
83
  tests/test_get_waterball.py
84
84
  tests/test_give_money.py
85
+ tests/test_goto_board_same_board.py
85
86
  tests/test_half_dead_connection.py
86
87
  tests/test_i18n.py
87
88
  tests/test_incremental_parity.py
@@ -103,4 +104,5 @@ tests/test_set_board_title.py
103
104
  tests/test_set_signature_file.py
104
105
  tests/test_status_line.py
105
106
  tests/test_uao_codec.py
106
- tests/test_vt100_parser.py
107
+ tests/test_vt100_parser.py
108
+ tests/test_websocket_endpoint.py
@@ -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 second (search) send at _api_get_newest_index.py:128 has its own
128
- flag check; the goto_board and first '$' sends must not mask it."""
129
- api = FakeApi(FakeConnectCore([(2, False), (2, False), (-1, True)],
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), (2, False), (-1, 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(
@@ -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
- api = _newest_api([_fx('newest_index_board.txt'), _fx('newest_index_in_post.txt')])
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([_fx('newest_index_board.txt'), screens.Target.Animation[0]])
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,
@@ -0,0 +1,39 @@
1
+ """No-network regression test for the WebSocket endpoint connect_core dials.
2
+
3
+ On 2026-11-06 PTT1's gateway started answering wss://ws.ptt.cc/bbs/ (with a
4
+ trailing slash) with HTTP 404, so login died with ConnectError before reaching
5
+ any screen. term.ptt.cc connects to wss://ws.ptt.cc/bbs, which still works.
6
+ """
7
+ import pytest
8
+
9
+ from PyPtt import config, connect_core, data_type
10
+
11
+
12
+ class FakeApi:
13
+ def __init__(self, host, port=23):
14
+ self.config = config.Config()
15
+ self.config.host = host
16
+ self.config.port = port
17
+
18
+
19
+ def _endpoint(host, port=23):
20
+ return connect_core.API(FakeApi(host, port))._websocket_endpoint()
21
+
22
+
23
+ @pytest.mark.parametrize('host, url, origin', [
24
+ (data_type.HOST.PTT1, 'wss://ws.ptt.cc/bbs', 'https://term.ptt.cc'),
25
+ (data_type.HOST.PTT2, 'wss://ws.ptt2.cc/bbs', 'https://term.ptt2.cc'),
26
+ (data_type.HOST.LOCALHOST, 'ws://localhost:48763/bbs', 'http://localhost'),
27
+ ])
28
+ def test_known_hosts(host, url, origin):
29
+ assert _endpoint(host) == (url, origin)
30
+
31
+
32
+ @pytest.mark.parametrize('host', [data_type.HOST.PTT1, data_type.HOST.PTT2, data_type.HOST.LOCALHOST])
33
+ def test_no_trailing_slash(host):
34
+ url, _ = _endpoint(host)
35
+ assert not url.endswith('/')
36
+
37
+
38
+ def test_localhost_custom_port():
39
+ assert _endpoint(data_type.HOST.LOCALHOST, port=12345)[0] == 'ws://localhost:12345/bbs'
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