diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0ebacc..d76679c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,8 @@ jobs: python-version: 3.9 - name: Install requirements run: pip install -r requirements.txt + - name: Install test requirements + run: pip install -r test-requirements.txt - name: Install pytest run: pip install pytest - name: Run pytest diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml index 88cbf3a..139f1c1 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/type-check.yml @@ -13,6 +13,8 @@ jobs: python-version: 3.9 - name: Install requirements run: pip install -r requirements.txt + - name: Install test requirements + run: pip install -r test-requirements.txt - name: Install mypy run: pip install mypy - name: Run mypy diff --git a/generate.py b/generate.py index 41978ec..240c82b 100755 --- a/generate.py +++ b/generate.py @@ -6,37 +6,20 @@ import argparse import asyncio -import functools -import json import logging -import os -import time -from functools import lru_cache -from typing import Any, Callable, Coroutine, Dict, Iterator, List, Tuple - -import diskcache # type: ignore +from typing import Any, Coroutine, List # https://github.com/kerrickstaley/genanki import genanki # type: ignore - -# https://github.com/prius/python-leetcode -import leetcode.api.default_api # type: ignore -import leetcode.api_client # type: ignore -import leetcode.auth # type: ignore -import leetcode.configuration # type: ignore -import leetcode.models.graphql_query # type: ignore -import leetcode.models.graphql_query_get_question_detail_variables # type: ignore -import urllib3 # type: ignore from tqdm import tqdm # type: ignore +import leetcode_anki.helpers.leetcode + LEETCODE_ANKI_MODEL_ID = 4567610856 LEETCODE_ANKI_DECK_ID = 8589798175 OUTPUT_FILE = "leetcode.apkg" -CACHE_DIR = "cache" ALLOWED_EXTENSIONS = {".py", ".go"} -leetcode_api_access_lock = asyncio.Lock() - logging.getLogger().setLevel(logging.INFO) @@ -58,241 +41,6 @@ def parse_args() -> argparse.Namespace: return args -def retry(times: int, exceptions: Tuple[Exception], delay: float) -> Callable: - """ - Retry Decorator - Retries the wrapped function/method `times` times if the exceptions listed - in `exceptions` are thrown - """ - - def decorator(func): - @functools.wraps(func) - async def wrapper(*args, **kwargs): - for attempt in range(times - 1): - try: - return await func(*args, **kwargs) - except exceptions: - logging.exception( - "Exception occured, try %s/%s", attempt + 1, times - ) - time.sleep(delay) - - logging.error("Last try") - return await func(*args, **kwargs) - - return wrapper - - return decorator - - -class LeetcodeData: - """ - Retrieves and caches the data for problems, acquired from the leetcode API. - - This data can be later accessed using provided methods with corresponding - names. - """ - - def __init__(self) -> None: - """ - Initialize leetcode API and disk cache for API responses - """ - self._api_instance = get_leetcode_api_client() - - if not os.path.exists(CACHE_DIR): - os.mkdir(CACHE_DIR) - self._cache = diskcache.Cache(CACHE_DIR) - - @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) - async def _get_problem_data(self, problem_slug: str) -> Dict[str, str]: - """ - Get data about a specific problem (method output if cached to reduce - the load on the leetcode API) - """ - if problem_slug in self._cache: - return self._cache[problem_slug] - - api_instance = self._api_instance - - graphql_request = leetcode.models.graphql_query.GraphqlQuery( - query=""" - query getQuestionDetail($titleSlug: String!) { - question(titleSlug: $titleSlug) { - freqBar - questionId - questionFrontendId - boundTopicId - title - content - translatedTitle - translatedContent - isPaidOnly - difficulty - likes - dislikes - isLiked - similarQuestions - contributors { - username - profileUrl - avatarUrl - __typename - } - langToValidPlayground - topicTags { - name - slug - translatedName - __typename - } - companyTagStats - codeSnippets { - lang - langSlug - code - __typename - } - stats - hints - solution { - id - canSeeDetail - __typename - } - status - sampleTestCase - metaData - judgerAvailable - judgeType - mysqlSchemas - enableRunCode - enableTestMode - envInfo - __typename - } - } - """, - variables=leetcode.models.graphql_query_get_question_detail_variables.GraphqlQueryGetQuestionDetailVariables( # noqa: E501 - title_slug=problem_slug - ), - operation_name="getQuestionDetail", - ) - - # Critical section. Don't allow more than one parallel request to - # the Leetcode API - async with leetcode_api_access_lock: - time.sleep(2) # Leetcode has a rate limiter - data = api_instance.graphql_post(body=graphql_request).data.question - - # Save data in the cache - self._cache[problem_slug] = data - - return data - - async def _get_description(self, problem_slug: str) -> str: - """ - Problem description - """ - data = await self._get_problem_data(problem_slug) - return data.content or "No content" - - async def _stats(self, problem_slug: str) -> Dict[str, str]: - """ - Various stats about problem. Such as number of accepted solutions, etc. - """ - data = await self._get_problem_data(problem_slug) - return json.loads(data.stats) - - async def submissions_total(self, problem_slug: str) -> int: - """ - Total number of submissions of the problem - """ - return int((await self._stats(problem_slug))["totalSubmissionRaw"]) - - async def submissions_accepted(self, problem_slug: str) -> int: - """ - Number of accepted submissions of the problem - """ - return int((await self._stats(problem_slug))["totalAcceptedRaw"]) - - async def description(self, problem_slug: str) -> str: - """ - Problem description - """ - return await self._get_description(problem_slug) - - async def difficulty(self, problem_slug: str) -> str: - """ - Problem difficulty. Returns colored HTML version, so it can be used - directly in Anki - """ - data = await self._get_problem_data(problem_slug) - diff = data.difficulty - - if diff == "Easy": - return "Easy" - - if diff == "Medium": - return "Medium" - - if diff == "Hard": - return "Hard" - - raise ValueError(f"Incorrect difficulty: {diff}") - - async def paid(self, problem_slug: str) -> str: - """ - Problem's "available for paid subsribers" status - """ - data = await self._get_problem_data(problem_slug) - return data.is_paid_only - - async def problem_id(self, problem_slug: str) -> str: - """ - Numerical id of the problem - """ - data = await self._get_problem_data(problem_slug) - return data.question_frontend_id - - async def likes(self, problem_slug: str) -> int: - """ - Number of likes for the problem - """ - data = await self._get_problem_data(problem_slug) - likes = data.likes - - if not isinstance(likes, int): - raise ValueError(f"Likes should be int: {likes}") - - return likes - - async def dislikes(self, problem_slug: str) -> int: - """ - Number of dislikes for the problem - """ - data = await self._get_problem_data(problem_slug) - dislikes = data.dislikes - - if not isinstance(dislikes, int): - raise ValueError(f"Dislikes should be int: {dislikes}") - - return dislikes - - async def tags(self, problem_slug: str) -> List[str]: - """ - List of the tags for this problem (string slugs) - """ - data = await self._get_problem_data(problem_slug) - return list(map(lambda x: x.slug, data.topic_tags)) - - async def freq_bar(self, problem_slug: str) -> float: - """ - Returns percentage for frequency bar - """ - data = await self._get_problem_data(problem_slug) - return data.freq_bar or 0 - - class LeetcodeNote(genanki.Note): """ Extended base class for the Anki note, that correctly sets the unique @@ -305,47 +53,8 @@ def guid(self): return genanki.guid_for(self.fields[0]) -@lru_cache(None) -def get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: - """ - Leetcode API instance constructor. - - This is a singleton, because we don't need to create a separate client - each time - """ - configuration = leetcode.configuration.Configuration() - - session_id = os.environ["LEETCODE_SESSION_ID"] - csrf_token = leetcode.auth.get_csrf_cookie(session_id) - - configuration.api_key["x-csrftoken"] = csrf_token - configuration.api_key["csrftoken"] = csrf_token - configuration.api_key["LEETCODE_SESSION"] = session_id - configuration.api_key["Referer"] = "https://leetcode.com" - configuration.debug = False - api_instance = leetcode.api.default_api.DefaultApi( - leetcode.api_client.ApiClient(configuration) - ) - - return api_instance - - -def get_leetcode_task_handles() -> Iterator[Tuple[str, str, str]]: - """ - Get task handles for all the leetcode problems. - """ - api_instance = get_leetcode_api_client() - - for topic in ["algorithms", "database", "shell", "concurrency"]: - api_response = api_instance.api_problems_topic_get(topic=topic) - for stat_status_pair in api_response.stat_status_pairs: - stat = stat_status_pair.stat - - yield (topic, stat.question__title, stat.question__title_slug) - - async def generate_anki_note( - leetcode_data: LeetcodeData, + leetcode_data: leetcode_anki.helpers.leetcode.LeetcodeData, leetcode_model: genanki.Model, leetcode_task_handle: str, leetcode_task_title: str, @@ -449,12 +158,12 @@ async def generate(start: int, stop: int) -> None: ], ) leetcode_deck = genanki.Deck(LEETCODE_ANKI_DECK_ID, "leetcode") - leetcode_data = LeetcodeData() + leetcode_data = leetcode_anki.helpers.leetcode.LeetcodeData() note_generators: List[Coroutine[Any, Any, LeetcodeNote]] = [] for topic, leetcode_task_title, leetcode_task_handle in list( - get_leetcode_task_handles() + leetcode_anki.helpers.leetcode.get_leetcode_task_handles() )[start:stop]: note_generators.append( generate_anki_note( diff --git a/leetcode_anki/__init__.py b/leetcode_anki/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode_anki/helpers/__init__.py b/leetcode_anki/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode_anki/helpers/leetcode.py b/leetcode_anki/helpers/leetcode.py new file mode 100644 index 0000000..a416f98 --- /dev/null +++ b/leetcode_anki/helpers/leetcode.py @@ -0,0 +1,298 @@ +import asyncio +import functools +import json +import logging +import os +import time +from functools import lru_cache +from typing import Callable, Dict, Iterator, List, Tuple, Type + +import diskcache # type: ignore + +# https://github.com/prius/python-leetcode +import leetcode.api.default_api # type: ignore +import leetcode.api_client # type: ignore +import leetcode.auth # type: ignore +import leetcode.configuration # type: ignore +import leetcode.models.graphql_query # type: ignore +import leetcode.models.graphql_query_get_question_detail_variables # type: ignore +import urllib3 # type: ignore + +CACHE_DIR = "cache" + + +leetcode_api_access_lock = asyncio.Lock() + + +@lru_cache(None) +def _get_leetcode_api_client() -> leetcode.api.default_api.DefaultApi: + """ + Leetcode API instance constructor. + + This is a singleton, because we don't need to create a separate client + each time + """ + configuration = leetcode.configuration.Configuration() + + session_id = os.environ["LEETCODE_SESSION_ID"] + csrf_token = leetcode.auth.get_csrf_cookie(session_id) + + configuration.api_key["x-csrftoken"] = csrf_token + configuration.api_key["csrftoken"] = csrf_token + configuration.api_key["LEETCODE_SESSION"] = session_id + configuration.api_key["Referer"] = "https://leetcode.com" + configuration.debug = False + api_instance = leetcode.api.default_api.DefaultApi( + leetcode.api_client.ApiClient(configuration) + ) + + return api_instance + + +def get_leetcode_task_handles() -> Iterator[Tuple[str, str, str]]: + """ + Get task handles for all the leetcode problems. + """ + api_instance = _get_leetcode_api_client() + + for topic in ["algorithms", "database", "shell", "concurrency"]: + api_response = api_instance.api_problems_topic_get(topic=topic) + for stat_status_pair in api_response.stat_status_pairs: + stat = stat_status_pair.stat + + yield (topic, stat.question__title, stat.question__title_slug) + + +def retry(times: int, exceptions: Tuple[Type[Exception]], delay: float) -> Callable: + """ + Retry Decorator + Retries the wrapped function/method `times` times if the exceptions listed + in `exceptions` are thrown + """ + + def decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + for attempt in range(times - 1): + try: + return await func(*args, **kwargs) + except exceptions: + logging.exception( + "Exception occured, try %s/%s", attempt + 1, times + ) + time.sleep(delay) + + logging.error("Last try") + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +class LeetcodeData: + """ + Retrieves and caches the data for problems, acquired from the leetcode API. + + This data can be later accessed using provided methods with corresponding + names. + """ + + def __init__(self) -> None: + """ + Initialize leetcode API and disk cache for API responses + """ + self._api_instance = _get_leetcode_api_client() + + if not os.path.exists(CACHE_DIR): + os.mkdir(CACHE_DIR) + self._cache = diskcache.Cache(CACHE_DIR) + + @retry(times=3, exceptions=(urllib3.exceptions.ProtocolError,), delay=5) + async def _get_problem_data(self, problem_slug: str) -> Dict[str, str]: + """ + Get data about a specific problem (method output if cached to reduce + the load on the leetcode API) + """ + if problem_slug in self._cache: + return self._cache[problem_slug] + + api_instance = self._api_instance + + graphql_request = leetcode.models.graphql_query.GraphqlQuery( + query=""" + query getQuestionDetail($titleSlug: String!) { + question(titleSlug: $titleSlug) { + freqBar + questionId + questionFrontendId + boundTopicId + title + content + translatedTitle + translatedContent + isPaidOnly + difficulty + likes + dislikes + isLiked + similarQuestions + contributors { + username + profileUrl + avatarUrl + __typename + } + langToValidPlayground + topicTags { + name + slug + translatedName + __typename + } + companyTagStats + codeSnippets { + lang + langSlug + code + __typename + } + stats + hints + solution { + id + canSeeDetail + __typename + } + status + sampleTestCase + metaData + judgerAvailable + judgeType + mysqlSchemas + enableRunCode + enableTestMode + envInfo + __typename + } + } + """, + variables=leetcode.models.graphql_query_get_question_detail_variables.GraphqlQueryGetQuestionDetailVariables( # noqa: E501 + title_slug=problem_slug + ), + operation_name="getQuestionDetail", + ) + + # Critical section. Don't allow more than one parallel request to + # the Leetcode API + async with leetcode_api_access_lock: + time.sleep(2) # Leetcode has a rate limiter + data = api_instance.graphql_post(body=graphql_request).data.question + + # Save data in the cache + self._cache[problem_slug] = data + + return data + + async def _get_description(self, problem_slug: str) -> str: + """ + Problem description + """ + data = await self._get_problem_data(problem_slug) + return data.content or "No content" + + async def _stats(self, problem_slug: str) -> Dict[str, str]: + """ + Various stats about problem. Such as number of accepted solutions, etc. + """ + data = await self._get_problem_data(problem_slug) + return json.loads(data.stats) + + async def submissions_total(self, problem_slug: str) -> int: + """ + Total number of submissions of the problem + """ + return int((await self._stats(problem_slug))["totalSubmissionRaw"]) + + async def submissions_accepted(self, problem_slug: str) -> int: + """ + Number of accepted submissions of the problem + """ + return int((await self._stats(problem_slug))["totalAcceptedRaw"]) + + async def description(self, problem_slug: str) -> str: + """ + Problem description + """ + return await self._get_description(problem_slug) + + async def difficulty(self, problem_slug: str) -> str: + """ + Problem difficulty. Returns colored HTML version, so it can be used + directly in Anki + """ + data = await self._get_problem_data(problem_slug) + diff = data.difficulty + + if diff == "Easy": + return "Easy" + + if diff == "Medium": + return "Medium" + + if diff == "Hard": + return "Hard" + + raise ValueError(f"Incorrect difficulty: {diff}") + + async def paid(self, problem_slug: str) -> str: + """ + Problem's "available for paid subsribers" status + """ + data = await self._get_problem_data(problem_slug) + return data.is_paid_only + + async def problem_id(self, problem_slug: str) -> str: + """ + Numerical id of the problem + """ + data = await self._get_problem_data(problem_slug) + return data.question_frontend_id + + async def likes(self, problem_slug: str) -> int: + """ + Number of likes for the problem + """ + data = await self._get_problem_data(problem_slug) + likes = data.likes + + if not isinstance(likes, int): + raise ValueError(f"Likes should be int: {likes}") + + return likes + + async def dislikes(self, problem_slug: str) -> int: + """ + Number of dislikes for the problem + """ + data = await self._get_problem_data(problem_slug) + dislikes = data.dislikes + + if not isinstance(dislikes, int): + raise ValueError(f"Dislikes should be int: {dislikes}") + + return dislikes + + async def tags(self, problem_slug: str) -> List[str]: + """ + List of the tags for this problem (string slugs) + """ + data = await self._get_problem_data(problem_slug) + return list(map(lambda x: x.slug, data.topic_tags)) + + async def freq_bar(self, problem_slug: str) -> float: + """ + Returns percentage for frequency bar + """ + data = await self._get_problem_data(problem_slug) + return data.freq_bar or 0 diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/helpers/__init__.py b/test/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/helpers/test_leetcode.py b/test/helpers/test_leetcode.py new file mode 100644 index 0000000..a0e7e46 --- /dev/null +++ b/test/helpers/test_leetcode.py @@ -0,0 +1,242 @@ +import sys +from typing import Optional +from unittest import mock + +import leetcode.models.graphql_data # type: ignore +import leetcode.models.graphql_question_contributor # type: ignore +import leetcode.models.graphql_question_detail # type: ignore +import leetcode.models.graphql_question_solution # type: ignore +import leetcode.models.graphql_question_topic_tag # type: ignore +import leetcode.models.graphql_response # type: ignore +import leetcode.models.problems # type: ignore +import leetcode.models.stat # type: ignore +import leetcode.models.stat_status_pair # type: ignore +import pytest + +import leetcode_anki.helpers.leetcode + + +@mock.patch("os.environ", mock.MagicMock(return_value={"LEETCODE_SESSION_ID": "test"})) +@mock.patch("leetcode.auth", mock.MagicMock()) +class TestLeetcode: + @pytest.mark.asyncio + async def test_get_leetcode_api_client(self) -> None: + assert leetcode_anki.helpers.leetcode._get_leetcode_api_client() + + @pytest.mark.asyncio + @mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client") + async def test_get_leetcode_task_handles(self, api_client: mock.Mock) -> None: + problems = leetcode.models.problems.Problems( + user_name="test", + num_solved=1, + num_total=1, + ac_easy=1, + ac_medium=1, + ac_hard=1, + frequency_high=1, + frequency_mid=1, + category_slug="test", + stat_status_pairs=[ + leetcode.models.stat_status_pair.StatStatusPair( + stat=leetcode.models.stat.Stat( + question_id=1, + question__hide=False, + question__title="Test 1", + question__title_slug="test1", + is_new_question=False, + frontend_question_id=1, + total_acs=1, + total_submitted=1, + ), + difficulty="easy", + is_favor=False, + status="ac", + paid_only=False, + frequency=0.0, + progress=1, + ), + ], + ) + api_client.return_value.api_problems_topic_get.return_value = problems + + assert list(leetcode_anki.helpers.leetcode.get_leetcode_task_handles()) == [ + ("algorithms", "Test 1", "test1"), + ("database", "Test 1", "test1"), + ("shell", "Test 1", "test1"), + ("concurrency", "Test 1", "test1"), + ] + + @pytest.mark.asyncio + async def test_retry(self) -> None: + decorator = leetcode_anki.helpers.leetcode.retry( + times=3, exceptions=(RuntimeError,), delay=0.01 + ) + + async def test() -> str: + return "test" + + func = mock.Mock(side_effect=[RuntimeError, RuntimeError, test()]) + + wrapper = decorator(func) + + await wrapper() == "test" + + func.call_count = 3 + + +class TestLeetcodeData: + _question_detail_singleton: Optional[ + leetcode.models.graphql_question_detail.GraphqlQuestionDetail + ] = None + _leetcode_data_singleton: Optional[ + leetcode_anki.helpers.leetcode.LeetcodeData + ] = None + + @property + def _question_details( + self, + ) -> leetcode.models.graphql_question_detail.GraphqlQuestionDetail: + question_detail = self._question_detail_singleton + + if not question_detail: + raise ValueError("Question detail must not be None") + + return question_detail + + @property + def _leetcode_data(self) -> leetcode_anki.helpers.leetcode.LeetcodeData: + leetcode_data = self._leetcode_data_singleton + + if not leetcode_data: + raise ValueError("Leetcode data must not be None") + + return leetcode_data + + @mock.patch("diskcache.Cache", mock.Mock(side_effect=lambda _: {})) + @mock.patch("os.path.exists", mock.Mock(return_value=True)) + @mock.patch("leetcode_anki.helpers.leetcode._get_leetcode_api_client") + def setup(self, leetcode_api: leetcode.api.default_api.DefaultApi) -> None: + self._question_detail_singleton = leetcode.models.graphql_question_detail.GraphqlQuestionDetail( + freq_bar=1.1, + question_id="1", + question_frontend_id="1", + bound_topic_id=1, + title="test title", + content="test content", + translated_title="test", + translated_content="test translated content", + is_paid_only=False, + difficulty="Hard", + likes=1, + dislikes=1, + is_liked=False, + similar_questions="{}", + contributors=[ + leetcode.models.graphql_question_contributor.GraphqlQuestionContributor( + username="testcontributor", + profile_url="test://profile/url", + avatar_url="test://avatar/url", + ), + ], + lang_to_valid_playground="{}", + topic_tags=[ + leetcode.models.graphql_question_topic_tag.GraphqlQuestionTopicTag( + name="test tag", + slug="test-tag", + translated_name="translated test tag", + typename="test type name", + ) + ], + company_tag_stats="{}", + code_snippets="{}", + stats='{"totalSubmissionRaw": 1, "totalAcceptedRaw": 1}', + hints=["test hint 1", "test hint 2"], + solution=[ + leetcode.models.graphql_question_solution.GraphqlQuestionSolution( + id=1, + can_see_detail=False, + typename="test type name", + ), + ], + status="ac", + sample_test_case="test case", + meta_data="{}", + judger_available=False, + judge_type="large", + mysql_schemas="test schema", + enable_run_code=False, + enable_test_mode=False, + env_info="{}", + ) + self._leetcode_data_singleton = leetcode_anki.helpers.leetcode.LeetcodeData() + + def test_init(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + @pytest.mark.asyncio + async def test_get_description(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + assert (await self._leetcode_data.description("test")) == "test content" + + @pytest.mark.asyncio + async def test_submissions(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + assert (await self._leetcode_data.submissions_total("test")) == 1 + assert (await self._leetcode_data.submissions_accepted("test")) == 1 + + @pytest.mark.asyncio + async def test_difficulty(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + self._leetcode_data._cache["test"].difficulty = "Easy" + assert "Easy" in (await self._leetcode_data.difficulty("test")) + + self._leetcode_data._cache["test"].difficulty = "Medium" + assert "Medium" in (await self._leetcode_data.difficulty("test")) + + self._leetcode_data._cache["test"].difficulty = "Hard" + assert "Hard" in (await self._leetcode_data.difficulty("test")) + + @pytest.mark.asyncio + async def test_paid(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + assert (await self._leetcode_data.paid("test")) is False + + @pytest.mark.asyncio + async def test_problem_id(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + assert (await self._leetcode_data.problem_id("test")) == "1" + + @pytest.mark.asyncio + async def test_likes(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + assert (await self._leetcode_data.likes("test")) == 1 + assert (await self._leetcode_data.dislikes("test")) == 1 + + @pytest.mark.asyncio + async def test_tags(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + assert (await self._leetcode_data.tags("test")) == ["test-tag"] + + @pytest.mark.asyncio + async def test_freq_bar(self) -> None: + self._leetcode_data._cache["test"] = self._question_details + + assert (await self._leetcode_data.freq_bar("test")) == 1.1 + + @mock.patch("time.sleep", mock.Mock()) + @pytest.mark.asyncio + async def test_get_problem_data(self) -> None: + data = leetcode.models.graphql_data.GraphqlData(question=self._question_details) + response = leetcode.models.graphql_response.GraphqlResponse(data=data) + self._leetcode_data._api_instance.graphql_post.return_value = response + + assert ( + await self._leetcode_data._get_problem_data("test") + ) == self._question_details + + assert self._leetcode_data._cache["test"] == self._question_details