diff --git a/tests/conftest.py b/tests/conftest.py index fe771d095c..8ca3157177 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,8 @@ CustomProfileField, Message, MessageType, + Stream, + Subscription, ) from zulipterminal.config.keys import ( ZT_TO_URWID_CMD_MAPPING, @@ -231,7 +233,7 @@ def logged_on_user() -> Dict[str, Any]: @pytest.fixture -def general_stream() -> Dict[str, Any]: +def general_stream() -> Subscription: return { "name": "Some general stream", "date_created": 1472091253, @@ -243,7 +245,6 @@ def general_stream() -> Dict[str, Any]: "audible_notifications": False, "description": "General Stream", "rendered_description": "General Stream", - "is_old_stream": True, "desktop_notifications": False, "stream_weekly_traffic": 0, "push_notifications": False, @@ -251,13 +252,18 @@ def general_stream() -> Dict[str, Any]: "message_retention_days": 10, "subscribers": [1001, 11, 12], "history_public_to_subscribers": True, + "is_announcement_only": False, + "first_message_id": 1, + "email_notifications": False, + "wildcard_mentions_notify": False, + "is_web_public": False, } # This is a private stream; # only description/stream_id/invite_only/name/color vary from above @pytest.fixture -def secret_stream() -> Dict[str, Any]: +def secret_stream() -> Subscription: return { "description": "Some private stream", "stream_id": 99, @@ -270,19 +276,23 @@ def secret_stream() -> Dict[str, Any]: "color": "#ccc", # Color in '#xxx' format "is_muted": False, "audible_notifications": False, - "is_old_stream": True, "desktop_notifications": False, "stream_weekly_traffic": 0, "message_retention_days": -1, "push_notifications": False, "subscribers": [1001, 11], "history_public_to_subscribers": False, + "is_announcement_only": False, + "first_message_id": 1, + "email_notifications": False, + "wildcard_mentions_notify": False, + "is_web_public": False, } # Like public stream but with is_web_public=True @pytest.fixture -def web_public_stream() -> Dict[str, Any]: +def web_public_stream() -> Subscription: return { "description": "Some web public stream", "stream_id": 999, @@ -295,7 +305,6 @@ def web_public_stream() -> Dict[str, Any]: "color": "#ddd", # Color in '#xxx' format "is_muted": False, "audible_notifications": False, - "is_old_stream": True, "desktop_notifications": False, "stream_weekly_traffic": 0, "message_retention_days": -1, @@ -303,15 +312,19 @@ def web_public_stream() -> Dict[str, Any]: "subscribers": [1001, 11], "history_public_to_subscribers": False, "is_web_public": True, + "is_announcement_only": False, + "first_message_id": 1, + "email_notifications": False, + "wildcard_mentions_notify": False, } @pytest.fixture def streams_fixture( - general_stream: Dict[str, Any], - secret_stream: Dict[str, Any], - web_public_stream: Dict[str, Any], -) -> List[Dict[str, Any]]: + general_stream: Subscription, + secret_stream: Subscription, + web_public_stream: Subscription, +) -> List[Subscription]: streams = [general_stream, secret_stream, web_public_stream] for i in range(1, 3): streams.append( @@ -326,7 +339,6 @@ def streams_fixture( "audible_notifications": False, "description": f"A description of stream {i}", "rendered_description": f"A description of stream {i}", - "is_old_stream": True, "desktop_notifications": False, "stream_weekly_traffic": 0, "push_notifications": False, @@ -334,11 +346,89 @@ def streams_fixture( "email_address": f"stream{i}@example.com", "subscribers": [1001, 11, 12], "history_public_to_subscribers": True, + "is_announcement_only": False, + "first_message_id": 1, + "email_notifications": False, + "wildcard_mentions_notify": False, + "is_web_public": False, } ) return deepcopy(streams) +@pytest.fixture +def unsubscribed_streams_fixture() -> List[Subscription]: + unsubscribed_streams: List[Subscription] = [] + for i in range(3, 5): + unsubscribed_streams.append( + { + "name": f"Stream {i}", + "date_created": 1472047124 + i, + "invite_only": False, + "color": "#b0a5fd", + "pin_to_top": False, + "stream_id": i, + "is_muted": False, + "audible_notifications": False, + "description": f"A description of stream {i}", + "rendered_description": f"A description of stream {i}", + "desktop_notifications": False, + "stream_weekly_traffic": 0, + "push_notifications": False, + "message_retention_days": i + 30, + "email_address": f"stream{i}@example.com", + "email_notifications": False, + "wildcard_mentions_notify": False, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": True, + "stream_post_policy": 0, + "is_web_public": True, + "first_message_id": None, + } + ) + return deepcopy(unsubscribed_streams) + + +@pytest.fixture +def never_subscribed_streams_fixture() -> List[Stream]: + never_subscribed_streams: List[Stream] = [] + for i in range(5, 7): + never_subscribed_streams.append( + { + "name": f"Stream {i}", + "date_created": 1472047124 + i, + "invite_only": False, + "stream_id": i, + "description": f"A description of stream {i}", + "rendered_description": f"A description of stream {i}", + "stream_weekly_traffic": 0, + "message_retention_days": i + 30, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": True, + "stream_post_policy": 0, + "is_web_public": True, + "first_message_id": None, + } + ) + return deepcopy(never_subscribed_streams) + + +@pytest.fixture +def all_stream_ids( + streams_fixture: List[Subscription], + unsubscribed_streams_fixture: List[Subscription], + never_subscribed_streams_fixture: List[Stream], +) -> List[int]: + return [ + stream["stream_id"] + for stream in streams_fixture + + unsubscribed_streams_fixture + + never_subscribed_streams_fixture + ] + + @pytest.fixture def realm_emojis() -> Dict[str, Dict[str, Any]]: # Omitting source_url, author_id (server version 3.0), @@ -872,7 +962,9 @@ def clean_custom_profile_data_fixture() -> List[CustomProfileData]: def initial_data( logged_on_user: Dict[str, Any], users_fixture: List[Dict[str, Any]], - streams_fixture: List[Dict[str, Any]], + streams_fixture: List[Subscription], + unsubscribed_streams_fixture: List[Subscription], + never_subscribed_streams_fixture: List[Stream], realm_emojis: Dict[str, Dict[str, Any]], custom_profile_fields_fixture: List[Dict[str, Union[str, int]]], ) -> Dict[str, Any]: @@ -884,24 +976,7 @@ def initial_data( "email": logged_on_user["email"], "user_id": logged_on_user["user_id"], "realm_name": "Test Organization Name", - "unsubscribed": [ - { - "audible_notifications": False, - "description": "announce", - "stream_id": 7, - "is_old_stream": True, - "desktop_notifications": False, - "pin_to_top": False, - "stream_weekly_traffic": 0, - "invite_only": False, - "name": "announce", - "push_notifications": False, - "email_address": "", - "color": "#bfd56f", - "is_muted": False, - "history_public_to_subscribers": True, - } - ], + "unsubscribed": unsubscribed_streams_fixture, "result": "success", "queue_id": "1522420755:786", "realm_users": users_fixture, @@ -950,24 +1025,7 @@ def initial_data( "subscriptions": streams_fixture, "msg": "", "max_message_id": 552761, - "never_subscribed": [ - { - "invite_only": False, - "description": "Announcements from the Zulip GCI Mentors", - "stream_id": 87, - "name": "GCI announce", - "is_old_stream": True, - "stream_weekly_traffic": 0, - }, - { - "invite_only": False, - "description": "General discussion", - "stream_id": 74, - "name": "GCI general", - "is_old_stream": True, - "stream_weekly_traffic": 0, - }, - ], + "never_subscribed": never_subscribed_streams_fixture, "unread_msgs": { "pms": [ {"sender_id": 1, "unread_message_ids": [1, 2]}, @@ -1433,10 +1491,30 @@ def user_id(logged_on_user: Dict[str, Any]) -> int: @pytest.fixture -def stream_dict(streams_fixture: List[Dict[str, Any]]) -> Dict[int, Any]: +def stream_dict(streams_fixture: List[Subscription]) -> Dict[int, Subscription]: return {stream["stream_id"]: stream for stream in streams_fixture} +@pytest.fixture +def unsubscribed_streams( + unsubscribed_streams_fixture: List[Subscription], +) -> Dict[int, Subscription]: + return { + unsubscribed_stream["stream_id"]: unsubscribed_stream + for unsubscribed_stream in unsubscribed_streams_fixture + } + + +@pytest.fixture +def never_subscribed_streams( + never_subscribed_streams_fixture: List[Stream], +) -> Dict[int, Stream]: + return { + never_subscribed_stream["stream_id"]: never_subscribed_stream + for never_subscribed_stream in never_subscribed_streams_fixture + } + + @pytest.fixture( params=[ { diff --git a/tests/core/test_core.py b/tests/core/test_core.py index 033f126fd7..5264232a2a 100644 --- a/tests/core/test_core.py +++ b/tests/core/test_core.py @@ -9,6 +9,7 @@ from pytest import param as case from pytest_mock import MockerFixture +from zulipterminal.api_types import Subscription from zulipterminal.config.themes import generate_theme from zulipterminal.core import Controller from zulipterminal.helper import Index @@ -126,18 +127,16 @@ def test_narrow_to_stream( mocker: MockerFixture, controller: Controller, index_stream: Index, + general_stream: Subscription, stream_id: int = 205, stream_name: str = "PTEST", ) -> None: controller.model.narrow = [] controller.model.index = index_stream controller.view.message_view = mocker.patch("urwid.ListBox") - controller.model.stream_dict = { - stream_id: { - "color": "#ffffff", - "name": stream_name, - } - } + mocker.patch(MODEL + ".stream_name_from_id", return_value=stream_name) + mocker.patch(MODEL + ".stream_id_from_name", return_value=stream_id) + mocker.patch(MODEL + ".is_user_subscribed_to_stream", return_value=True) controller.model.muted_streams = set() mocker.patch(MODEL + ".is_muted_topic", return_value=False) @@ -173,6 +172,7 @@ def test_narrow_to_topic( initial_stream_id: Optional[int], anchor: Optional[int], expected_final_focus: int, + general_stream: Subscription, stream_name: str = "PTEST", topic_name: str = "Test", stream_id: int = 205, @@ -185,12 +185,9 @@ def test_narrow_to_topic( controller.model.index = index_multiple_topic_msg controller.model.stream_id = initial_stream_id controller.view.message_view = mocker.patch("urwid.ListBox") - controller.model.stream_dict = { - stream_id: { - "color": "#ffffff", - "name": stream_name, - } - } + mocker.patch(MODEL + ".stream_name_from_id", return_value=stream_name) + mocker.patch(MODEL + ".stream_id_from_name", return_value=stream_id) + mocker.patch(MODEL + ".is_user_subscribed_to_stream", return_value=True) controller.model.muted_streams = set() mocker.patch(MODEL + ".is_muted_topic", return_value=False) @@ -255,18 +252,16 @@ def test_narrow_to_all_messages( controller: Controller, index_all_messages: Index, anchor: Optional[int], + general_stream: Subscription, expected_final_focus_msg_id: int, + stream_id: int = 205, ) -> None: controller.model.narrow = [["stream", "PTEST"]] controller.model.index = index_all_messages controller.view.message_view = mocker.patch("urwid.ListBox") controller.model.user_email = "some@email" controller.model.user_id = 1 - controller.model.stream_dict = { - 205: { - "color": "#ffffff", - } - } + mocker.patch(MODEL + ".is_user_subscribed_to_stream", return_value=True) controller.model.muted_streams = set() mocker.patch(MODEL + ".is_muted_topic", return_value=False) @@ -302,7 +297,12 @@ def test_narrow_to_all_pm( assert msg_ids == id_list def test_narrow_to_all_starred( - self, mocker: MockerFixture, controller: Controller, index_all_starred: Index + self, + mocker: MockerFixture, + controller: Controller, + index_all_starred: Index, + general_stream: Subscription, + stream_id: int = 205, ) -> None: controller.model.narrow = [] controller.model.index = index_all_starred @@ -311,11 +311,7 @@ def test_narrow_to_all_starred( # FIXME: Expand upon is_muted_topic(). mocker.patch(MODEL + ".is_muted_topic", return_value=False) controller.model.user_email = "some@email" - controller.model.stream_dict = { - 205: { - "color": "#ffffff", - } - } + mocker.patch(MODEL + ".is_user_subscribed_to_stream", return_value=True) controller.view.message_view = mocker.patch("urwid.ListBox") controller.narrow_to_all_starred() # FIXME: Add id narrowing test @@ -329,7 +325,12 @@ def test_narrow_to_all_starred( assert msg_ids == id_list def test_narrow_to_all_mentions( - self, mocker: MockerFixture, controller: Controller, index_all_mentions: Index + self, + mocker: MockerFixture, + controller: Controller, + index_all_mentions: Index, + general_stream: Subscription, + stream_id: int = 205, ) -> None: controller.model.narrow = [] controller.model.index = index_all_mentions @@ -338,11 +339,7 @@ def test_narrow_to_all_mentions( mocker.patch(MODEL + ".is_muted_topic", return_value=False) controller.model.user_email = "some@email" controller.model.user_id = 1 - controller.model.stream_dict = { - 205: { - "color": "#ffffff", - } - } + mocker.patch(MODEL + ".is_user_subscribed_to_stream", return_value=True) controller.view.message_view = mocker.patch("urwid.ListBox") controller.narrow_to_all_mentions() # FIXME: Add id narrowing test diff --git a/tests/model/test_model.py b/tests/model/test_model.py index 915621f8fc..9ae38a45ec 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -63,6 +63,8 @@ def test_init( realm_emojis_data, zulip_emoji, stream_dict, + unsubscribed_streams, + never_subscribed_streams, ): assert hasattr(model, "controller") assert hasattr(model, "client") @@ -70,6 +72,8 @@ def test_init( assert model._have_last_message == {} assert model.stream_id is None assert model.stream_dict == stream_dict + assert model._unsubscribed_streams == unsubscribed_streams + assert model._never_subscribed_streams == never_subscribed_streams assert model.recipients == frozenset() assert model.index == initial_index assert model.last_unread_pm is None @@ -1699,6 +1703,267 @@ def test__update_users_data_from_initial_data( assert model.user_dict == user_dict assert model.users == user_list + @pytest.mark.parametrize( + "stream_id, expected_value", + [ + case( + 1000, + { + "name": "Some general stream", + "date_created": None, + "invite_only": False, + "color": "#baf", + "pin_to_top": False, + "stream_id": 1000, + "is_muted": False, + "audible_notifications": False, + "description": "General Stream", + "rendered_description": "General Stream", + "desktop_notifications": False, + "stream_weekly_traffic": 0, + "push_notifications": False, + "email_address": "general@example.comm", + "message_retention_days": None, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": False, + "first_message_id": 1, + "email_notifications": False, + "wildcard_mentions_notify": False, + "is_web_public": False, + }, + id="subscribed_stream", + ), + case( + 3, + { + "name": "Stream 3", + "date_created": 1472047127, + "invite_only": False, + "color": "#b0a5fd", + "pin_to_top": False, + "stream_id": 3, + "is_muted": False, + "audible_notifications": False, + "description": "A description of stream 3", + "rendered_description": "A description of stream 3", + "desktop_notifications": False, + "stream_weekly_traffic": 0, + "push_notifications": False, + "message_retention_days": 33, + "email_address": "stream3@example.com", + "email_notifications": False, + "wildcard_mentions_notify": False, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": True, + "stream_post_policy": 0, + "is_web_public": True, + "first_message_id": None, + }, + id="unsubscribed_stream", + ), + case( + 5, + { + "name": "Stream 5", + "date_created": 1472047129, + "invite_only": False, + "stream_id": 5, + "description": "A description of stream 5", + "rendered_description": "A description of stream 5", + "stream_weekly_traffic": 0, + "message_retention_days": 35, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": True, + "stream_post_policy": 0, + "is_web_public": True, + "first_message_id": None, + }, + id="never_subscribed_stream", + ), + ], + ) + def test__get_stream_from_id( + self, + model, + stream_id, + expected_value, + stream_dict, + unsubscribed_streams, + never_subscribed_streams, + ): + model.stream_dict = stream_dict + model._unsubscribed_streams = unsubscribed_streams + model._never_subscribed_streams = never_subscribed_streams + assert model._get_stream_from_id(stream_id) == expected_value + + def test__get_stream_from_id__nonexistent_stream( + self, + model, + stream_dict, + unsubscribed_streams, + never_subscribed_streams, + stream_id=231, # id 231 does not belong to any stream + ): + assert stream_id not in { + **stream_dict, + **unsubscribed_streams, + **never_subscribed_streams, + } + model.stream_dict = stream_dict + model._unsubscribed_streams = unsubscribed_streams + model._never_subscribed_streams = never_subscribed_streams + with pytest.raises(RuntimeError): + model._get_stream_from_id(stream_id) + + @pytest.mark.parametrize( + "stream_id, expected_value", + [ + case( + 1000, + { + "name": "Some general stream", + "date_created": None, + "invite_only": False, + "color": "#baf", + "pin_to_top": False, + "stream_id": 1000, + "is_muted": False, + "audible_notifications": False, + "description": "General Stream", + "rendered_description": "General Stream", + "desktop_notifications": False, + "stream_weekly_traffic": 0, + "push_notifications": False, + "email_address": "general@example.comm", + "message_retention_days": None, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": False, + "first_message_id": 1, + "email_notifications": False, + "wildcard_mentions_notify": False, + "is_web_public": False, + }, + id="subscribed_stream", + ), + case( + 3, + { + "name": "Stream 3", + "date_created": 1472047127, + "invite_only": False, + "color": "#b0a5fd", + "pin_to_top": False, + "stream_id": 3, + "is_muted": False, + "audible_notifications": False, + "description": "A description of stream 3", + "rendered_description": "A description of stream 3", + "desktop_notifications": False, + "stream_weekly_traffic": 0, + "push_notifications": False, + "message_retention_days": 33, + "email_address": "stream3@example.com", + "email_notifications": False, + "wildcard_mentions_notify": False, + "subscribers": [1001, 11, 12], + "history_public_to_subscribers": True, + "is_announcement_only": True, + "stream_post_policy": 0, + "is_web_public": True, + "first_message_id": None, + }, + id="unsubscribed_stream", + ), + ], + ) + def test__get_subscription_from_id( + self, + model, + stream_id, + expected_value, + stream_dict, + unsubscribed_streams, + ): + model.stream_dict = stream_dict + model._unsubscribed_streams = unsubscribed_streams + assert model._get_subscription_from_id(stream_id) == expected_value + + @pytest.mark.parametrize( + "stream_id", + [case(5, id="never_subscribed_stream"), case(231, id="non-existent_stream")], + ) + def test__get_subscription_from_id__nonexistent_subscription( + self, + model, + stream_dict, + unsubscribed_streams, + never_subscribed_streams, + stream_id, + ): + model.stream_dict = stream_dict + model._unsubscribed_streams = unsubscribed_streams + model._never_subscribed_streams = never_subscribed_streams + with pytest.raises(RuntimeError): + model._get_subscription_from_id(stream_id) + + def test_get_all_stream_ids( + self, + model, + stream_dict, + unsubscribed_streams, + never_subscribed_streams, + all_stream_ids, + ): + model.stream_dict = stream_dict + model._unsubscribed_streams = unsubscribed_streams + model._never_subscribed_streams = never_subscribed_streams + assert model.get_all_stream_ids() == all_stream_ids + + @pytest.mark.parametrize( + "stream_id, expected_stream_name", + [ + case( + 1000, + "Some general stream", + id="Subscribed stream", + ), + case( + 3, + "Stream 3", + id="Unsubscribed stream", + ), + case( + 5, + "Stream 5", + id="Never subscribed stream", + ), + ], + ) + def test_stream_name_from_id(self, model, stream_id, expected_stream_name): + assert model.stream_name_from_id(stream_id) == expected_stream_name + + @pytest.mark.parametrize( + "stream_id, expected_stream_color", + [ + case( + 1000, + "#baf", + id="Subscribed stream", + ), + case( + 3, + "#baf", + id="Unsubscribed stream", + ), + ], + ) + def test_subscription_color_from_id(self, model, stream_id, expected_stream_color): + assert model.subscription_color_from_id(stream_id) == expected_stream_color + @pytest.mark.parametrize("muted", powerset([99, 1000])) @pytest.mark.parametrize("visual_notification_enabled", powerset([99, 1000])) def test__subscribe_to_streams( diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index 18d2ef27b3..bba5c4594f 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -695,7 +695,6 @@ def test_update_topics_list( ): mocker.patch(SUBDIR + ".buttons.TopButton.__init__", return_value=None) set_focus_valign = mocker.patch(VIEWS + ".urwid.ListBox.set_focus_valign") - topic_view.view.controller.model.stream_dict = {86: {"name": "PTEST"}} topic_view.view.controller.model.is_muted_topic = mocker.Mock( return_value=False ) @@ -918,7 +917,7 @@ def test_keypress_NEXT_UNREAD_TOPIC_stream( mocker.patch(MIDCOLVIEW + ".focus_position") mocker.patch.object(self.view, "message_view") - mid_col_view.model.stream_dict = {1: {"name": "stream"}} + mid_col_view.model.stream_name_from_id.return_value = "stream" mid_col_view.model.next_unread_topic_from_message_id.return_value = (1, "topic") return_value = mid_col_view.keypress(size, key) diff --git a/tests/ui_tools/test_boxes.py b/tests/ui_tools/test_boxes.py index 869cc981b9..1b45b687b7 100644 --- a/tests/ui_tools/test_boxes.py +++ b/tests/ui_tools/test_boxes.py @@ -153,11 +153,11 @@ def test_generic_autocomplete_stream_and_topic( is_valid_stream: bool, required_typeahead: Optional[str], topics: List[str], - stream_dict: Dict[int, Dict[str, Any]], + all_stream_ids: List[int], ) -> None: write_box.model.topics_in_stream.return_value = topics write_box.model.is_valid_stream.return_value = is_valid_stream - write_box.model.stream_dict = stream_dict + write_box.model.get_all_stream_ids.return_value = all_stream_ids write_box.model.muted_streams = set() typeahead_string = write_box.generic_autocomplete(text, state) @@ -573,12 +573,12 @@ def test_generic_autocomplete_set_footer( state: Optional[int], footer_text: List[Any], text: str, - stream_dict: Dict[int, Dict[str, Any]], + all_stream_ids: List[int], ) -> None: write_box.view.set_typeahead_footer = mocker.patch( "zulipterminal.ui.View.set_typeahead_footer" ) - write_box.model.stream_dict = stream_dict + write_box.model.get_all_stream_ids.return_value = all_stream_ids write_box.model.muted_streams = set() write_box.generic_autocomplete(text, state) @@ -941,6 +941,7 @@ def test_generic_autocomplete_streams( state_and_required_typeahead: Dict[int, Optional[str]], stream_categories: Dict[str, Any], stream_dict: Dict[int, Dict[str, Any]], + all_stream_ids: List[int], ) -> None: streams_to_pin = ( [{"name": stream_name} for stream_name in stream_categories["pinned"]] @@ -951,12 +952,15 @@ def test_generic_autocomplete_streams( write_box.view.unpinned_streams.remove(stream) write_box.view.pinned_streams = streams_to_pin write_box.stream_id = stream_categories.get("current_stream", None) - write_box.model.stream_dict = stream_dict write_box.model.muted_streams = { stream["stream_id"] for stream in stream_dict.values() if stream["name"] in stream_categories.get("muted", set()) } + write_box.model.get_all_stream_ids.return_value = all_stream_ids + write_box.model.stream_name_from_id.side_effect = lambda x: stream_dict[x][ + "name" + ] states = state_and_required_typeahead.keys() required_typeaheads = list(state_and_required_typeahead.values()) typeahead_strings = [ @@ -1278,7 +1282,9 @@ def test__set_stream_write_box_style_markers( expected_color: str, ) -> None: # FIXME: Refactor when we have ~ Model.is_private_stream - write_box.model.stream_dict = stream_dict + write_box.model.subscription_color_from_id.side_effect = lambda x: stream_dict[ + x + ]["color"] write_box.model.is_valid_stream.return_value = is_valid_stream write_box.model.stream_id_from_name.return_value = stream_id write_box.model.stream_access_type.return_value = stream_access_type diff --git a/tests/ui_tools/test_buttons.py b/tests/ui_tools/test_buttons.py index 3705bbaefe..e4a6808b0c 100644 --- a/tests/ui_tools/test_buttons.py +++ b/tests/ui_tools/test_buttons.py @@ -434,16 +434,13 @@ def test_init_calls_top_button( is_resolved: bool, ) -> None: controller = mocker.Mock() - controller.model.stream_dict = { - 205: {"name": "PTEST"}, - 86: {"name": "Django"}, - 14: {"name": "GSoC"}, - } controller.model.is_muted_topic = mocker.Mock(return_value=False) view = mocker.Mock() top_button = mocker.patch(MODULE + ".TopButton.__init__") params = dict(controller=controller, count=count) + controller.model.stream_name_from_id.return_value = stream_name + topic_button = TopicButton( stream_id=stream_id, topic=title, view=view, **params ) @@ -486,7 +483,6 @@ def test_init_calls_mark_muted( controller.model.is_muted_topic = mocker.Mock( return_value=is_muted_topic_return_value ) - controller.model.stream_dict = {205: {"name": stream_name}} view = mocker.Mock() TopicButton( stream_id=205, @@ -897,14 +893,12 @@ def test__parse_narrow_link( ) def test__validate_narrow_link( self, - stream_dict: Dict[int, Any], parsed_link: ParsedNarrowLink, is_user_subscribed_to_stream: Optional[bool], is_valid_stream: Optional[bool], topics_in_stream: Optional[List[str]], expected_error: str, ) -> None: - self.controller.model.stream_dict = stream_dict self.controller.model.is_user_subscribed_to_stream.return_value = ( is_user_subscribed_to_stream ) @@ -922,6 +916,7 @@ def test__validate_narrow_link( "is_user_subscribed_to_stream", "is_valid_stream", "stream_id_from_name_return_value", + "stream_name_from_id_return_value", "expected_parsed_link", "expected_error", ], @@ -933,6 +928,7 @@ def test__validate_narrow_link( True, None, None, + "Stream 1", ParsedNarrowLink( stream=DecodedStream(stream_id=1, stream_name="Stream 1") ), @@ -945,6 +941,7 @@ def test__validate_narrow_link( False, None, None, + None, ParsedNarrowLink(stream=DecodedStream(stream_id=462, stream_name=None)), "The stream seems to be either unknown or unsubscribed", ), @@ -955,6 +952,7 @@ def test__validate_narrow_link( None, True, 1, + None, ParsedNarrowLink( stream=DecodedStream(stream_id=1, stream_name="Stream 1") ), @@ -967,6 +965,7 @@ def test__validate_narrow_link( None, False, None, + "foo", ParsedNarrowLink( stream=DecodedStream(stream_id=None, stream_name="foo") ), @@ -982,15 +981,14 @@ def test__validate_narrow_link( ) def test__validate_and_patch_stream_data( self, - stream_dict: Dict[int, Any], parsed_link: ParsedNarrowLink, is_user_subscribed_to_stream: Optional[bool], is_valid_stream: Optional[bool], stream_id_from_name_return_value: Optional[int], + stream_name_from_id_return_value: Optional[str], expected_parsed_link: ParsedNarrowLink, expected_error: str, ) -> None: - self.controller.model.stream_dict = stream_dict self.controller.model.stream_id_from_name.return_value = ( stream_id_from_name_return_value ) @@ -1000,6 +998,10 @@ def test__validate_and_patch_stream_data( self.controller.model.is_valid_stream.return_value = is_valid_stream mocked_button = self.message_link_button() + mocked_button.model.stream_name_from_id.return_value = ( + stream_name_from_id_return_value + ) + error = mocked_button._validate_and_patch_stream_data(parsed_link) assert parsed_link == expected_parsed_link diff --git a/tests/ui_tools/test_messages.py b/tests/ui_tools/test_messages.py index d4d9e806b4..16c3ef79cc 100644 --- a/tests/ui_tools/test_messages.py +++ b/tests/ui_tools/test_messages.py @@ -754,11 +754,6 @@ def test_soup2markup(self, content, expected_markup, mocker): ], ) def test_main_view(self, mocker, message, last_message): - self.model.stream_dict = { - 5: { - "color": "#bd6", - }, - } MessageBox(message, self.model, last_message) @pytest.mark.parametrize( @@ -833,11 +828,6 @@ def test_main_view_renders_slash_me(self, mocker, message, content, is_me_messag def test_main_view_generates_stream_header( self, mocker, message, to_vary_in_last_message ): - self.model.stream_dict = { - 5: { - "color": "#bd6", - }, - } last_message = dict(message, **to_vary_in_last_message) msg_box = MessageBox(message, self.model, last_message) view_components = msg_box.main_view() @@ -1026,11 +1016,7 @@ def test_msg_generates_search_and_header_bar( assert_header_bar, assert_search_bar, ): - self.model.stream_dict = { - 205: { - "color": "#bd6", - }, - } + self.model.subscription_color_from_id.return_value = "#bd6" self.model.narrow = msg_narrow messages = messages_successful_response["messages"] current_message = messages[msg_type] diff --git a/zulipterminal/api_types.py b/zulipterminal/api_types.py index 8acf4d05e2..4b193e0aaa 100644 --- a/zulipterminal/api_types.py +++ b/zulipterminal/api_types.py @@ -204,21 +204,39 @@ class Message(TypedDict, total=False): ############################################################################### -# In "subscriptions" response from: +# In "subscriptions", "unsubscribed", and "never_subscribed" responses from: # https://zulip.com/api/register-queue # Also directly from: # https://zulip.com/api/get-events#subscription-add # https://zulip.com/api/get-subscriptions (unused) -class Subscription(TypedDict): +class Stream(TypedDict): stream_id: int name: str description: str rendered_description: str - date_created: int # NOTE: new in Zulip 4.0 / ZFL 30 + + # NOTE: new in Zulip 4.0 / ZFL 30, server data may not contain this field, + # in which case ZT adds it and sets it to None. + date_created: NotRequired[Optional[int]] + invite_only: bool subscribers: List[int] + + is_announcement_only: bool # Deprecated in Zulip 3.0 -> stream_post_policy + + # NOTE: new in Zulip 3.0 / ZFL 1, server versions < 3.0 may not contain this field + stream_post_policy: NotRequired[int] + + is_web_public: bool + message_retention_days: Optional[int] # NOTE: new in Zulip 3.0 / ZFL 17 + history_public_to_subscribers: bool + first_message_id: Optional[int] + stream_weekly_traffic: Optional[int] + + +class Subscription(Stream): desktop_notifications: Optional[bool] email_notifications: Optional[bool] wildcard_mentions_notify: Optional[bool] @@ -229,20 +247,16 @@ class Subscription(TypedDict): is_muted: bool - is_announcement_only: bool # Deprecated in Zulip 3.0 -> stream_post_policy - stream_post_policy: int # NOTE: new in Zulip 3.0 / ZFL 1 - - is_web_public: bool - role: int # NOTE: new in Zulip 4.0 / ZFL 31 color: str - message_retention_days: Optional[int] # NOTE: new in Zulip 3.0 / ZFL 17 - history_public_to_subscribers: bool - first_message_id: Optional[int] - stream_weekly_traffic: Optional[int] # Deprecated fields + # in_home_view: bool # Replaced by is_muted in Zulip 2.1; still present in updates + # Introduced in Zulip 4.0 / ZFL 31, removed in Zulip 6.0 / ZFL 133; + # not actively used by ZT or Zulip web + # role: int + ############################################################################### # In "custom_profile_fields" response from: diff --git a/zulipterminal/model.py b/zulipterminal/model.py index 9c6fce61b5..64cf957f58 100644 --- a/zulipterminal/model.py +++ b/zulipterminal/model.py @@ -45,6 +45,7 @@ PrivateMessageUpdateRequest, RealmEmojiData, RealmUser, + Stream, StreamComposition, StreamMessageUpdateRequest, Subscription, @@ -176,12 +177,19 @@ def __init__(self, controller: Any) -> None: self.users: List[MinimalUserData] = [] self._update_users_data_from_initial_data() - self.stream_dict: Dict[int, Any] = {} + self.stream_dict: Dict[int, Subscription] = {} + self._unsubscribed_streams: Dict[int, Subscription] = {} + self._never_subscribed_streams: Dict[int, Stream] = {} self.muted_streams: Set[int] = set() self.pinned_streams: List[StreamData] = [] self.unpinned_streams: List[StreamData] = [] self.visual_notified_streams: Set[int] = set() + self._register_non_subscribed_streams( + unsubscribed_streams=self.initial_data["unsubscribed"], + never_subscribed_streams=self.initial_data["never_subscribed"], + ) + self._subscribe_to_streams(self.initial_data["subscriptions"]) # NOTE: The date_created field of stream has been added in feature @@ -882,7 +890,7 @@ def is_muted_topic(self, stream_id: int, topic: str) -> bool: """ Returns True if topic is muted via muted_topics. """ - stream_name = self.stream_dict[stream_id]["name"] + stream_name = self.stream_name_from_id(stream_id) topic_to_search = (stream_name, topic) return topic_to_search in self._muted_topics @@ -1269,6 +1277,53 @@ def user_name_from_id(self, user_id: int) -> str: return self.user_dict[user_email]["full_name"] + def _register_non_subscribed_streams( + self, + unsubscribed_streams: List[Subscription], + never_subscribed_streams: List[Stream], + ) -> None: + self._unsubscribed_streams = { + subscription["stream_id"]: subscription + for subscription in unsubscribed_streams + } + self._never_subscribed_streams = { + stream["stream_id"]: stream for stream in never_subscribed_streams + } + + def _get_stream_from_id(self, stream_id: int) -> Stream: + if stream_id in self.stream_dict: + return cast(Stream, self.stream_dict[stream_id]) + elif stream_id in self._unsubscribed_streams: + return cast(Stream, self._unsubscribed_streams[stream_id]) + elif stream_id in self._never_subscribed_streams: + return self._never_subscribed_streams[stream_id] + else: + raise RuntimeError(f"Stream with id {stream_id} does not exist!") + + def _get_subscription_from_id(self, subscription_id: int) -> Subscription: + if subscription_id in self.stream_dict: + return self.stream_dict[subscription_id] + elif subscription_id in self._unsubscribed_streams: + return self._unsubscribed_streams[subscription_id] + else: + raise RuntimeError( + f"Stream with id {subscription_id} does not exist or never subscribed to!" # noqa: E501 + ) + + def get_all_stream_ids(self) -> List[int]: + id_list = list(self.stream_dict) + id_list.extend(stream_id for stream_id in self._unsubscribed_streams) + id_list.extend(stream_id for stream_id in self._never_subscribed_streams) + return id_list + + def stream_name_from_id(self, stream_id: int) -> str: + stream = self._get_stream_from_id(stream_id) + return stream["name"] + + def subscription_color_from_id(self, subscription_id: int) -> str: + subscription = self._get_subscription_from_id(subscription_id) + return canonicalize_color(subscription["color"]) + def _subscribe_to_streams(self, subscriptions: List[Subscription]) -> None: def make_reduced_stream_data(stream: Subscription) -> StreamData: # stream_id has been changed to id. diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py index db5f396715..73261c5529 100644 --- a/zulipterminal/ui_tools/boxes.py +++ b/zulipterminal/ui_tools/boxes.py @@ -412,8 +412,7 @@ def _set_stream_write_box_style(self, widget: ReadlineEdit, new_text: str) -> No stream_id = self.model.stream_id_from_name(new_text) stream_access_type = self.model.stream_access_type(stream_id) stream_marker = STREAM_ACCESS_TYPE[stream_access_type]["icon"] - stream = self.model.stream_dict[stream_id] - color = stream["color"] + color = self.model.subscription_color_from_id(stream_id) self.header_write_box[self.FOCUS_HEADER_PREFIX_STREAM].set_text( (color, stream_marker) ) @@ -618,7 +617,7 @@ def autocomplete_streams( ) muted_streams = [ - self.model.stream_dict[stream_id]["name"] + self.model.stream_name_from_id(stream_id) for stream_id in self.model.muted_streams ] matching_muted_streams = [ @@ -637,9 +636,8 @@ def autocomplete_streams( else: matched_streams.append(matching_muted_stream) - current_stream = self.model.stream_dict.get(self.stream_id, None) - if current_stream is not None: - current_stream_name = current_stream["name"] + if self.stream_id in self.model.get_all_stream_ids(): + current_stream_name = self.model.stream_name_from_id(self.stream_id) if current_stream_name in matched_streams: matched_streams.remove(current_stream_name) matched_streams.insert(0, current_stream_name) diff --git a/zulipterminal/ui_tools/buttons.py b/zulipterminal/ui_tools/buttons.py index 748c86c2f0..56bfa7095e 100644 --- a/zulipterminal/ui_tools/buttons.py +++ b/zulipterminal/ui_tools/buttons.py @@ -317,7 +317,7 @@ def __init__( view: Any, count: int, ) -> None: - self.stream_name = controller.model.stream_dict[stream_id]["name"] + self.stream_name = controller.model.stream_name_from_id(stream_id) self.topic_name = topic self.stream_id = stream_id self.model = controller.model @@ -586,7 +586,7 @@ def _validate_and_patch_stream_data(self, parsed_link: ParsedNarrowLink) -> str: stream_id = cast(int, model.stream_id_from_name(stream_name)) parsed_link["stream"]["stream_id"] = stream_id else: - stream_name = cast(str, model.stream_dict[stream_id]["name"]) + stream_name = cast(str, model.stream_name_from_id(stream_id)) parsed_link["stream"]["stream_name"] = stream_name return "" diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py index 1a9bb9db02..1eb058eb6e 100644 --- a/zulipterminal/ui_tools/messages.py +++ b/zulipterminal/ui_tools/messages.py @@ -155,7 +155,7 @@ def _is_private_message_to_self(self) -> bool: def stream_header(self) -> Any: assert self.stream_id is not None - color = self.model.stream_dict[self.stream_id]["color"] + color = self.model.subscription_color_from_id(self.stream_id) bar_color = f"s{color}" stream_access_type = self.model.stream_access_type(self.stream_id) stream_icon = STREAM_ACCESS_TYPE[stream_access_type]["icon"] @@ -224,7 +224,7 @@ def top_search_bar(self) -> Any: elif self.message["type"] == "stream": assert self.stream_id is not None - bar_color = self.model.stream_dict[self.stream_id]["color"] + bar_color = self.model.subscription_color_from_id(self.stream_id) bar_color = f"s{bar_color}" stream_access_type = self.model.stream_access_type(self.stream_id) stream_icon = STREAM_ACCESS_TYPE[stream_access_type]["icon"] diff --git a/zulipterminal/ui_tools/views.py b/zulipterminal/ui_tools/views.py index fcb116480d..34a605eb1c 100644 --- a/zulipterminal/ui_tools/views.py +++ b/zulipterminal/ui_tools/views.py @@ -590,8 +590,8 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: # For new streams with no previous conversation. if self.footer.focus is None: stream_id = self.model.stream_id - stream_dict = self.model.stream_dict - self.footer.stream_box_view(caption=stream_dict[stream_id]["name"]) + stream_name = self.model.stream_name_from_id(stream_id) + self.footer.stream_box_view(caption=stream_name) self.set_focus("footer") self.footer.focus_position = 0 return key @@ -621,7 +621,7 @@ def keypress(self, size: urwid_Size, key: str) -> Optional[str]: stream_id, topic = stream_topic self.controller.narrow_to_topic( - stream_name=self.model.stream_dict[stream_id]["name"], + stream_name=self.model.stream_name_from_id(stream_id), topic_name=topic, ) return key