diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7f17dd0..ebcf246 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -54,7 +54,11 @@ jobs: - name: Install dependencies run: pip3 install -r requirements.txt -r requirements-dev.txt - name: Run Pytest - run: pytest tests + run: | + coverage run -m pytest + coverage xml + - name: Upload coverage report to CodeCov + uses: codecov/codecov-action@v3 linting: needs: [dependencies] runs-on: ubuntu-latest diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..6ce17c3 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,14 @@ +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false # if true: only post the comment if coverage changes + require_base: no # [yes :: must have a base report to post] + require_head: yes # [yes :: must have a head report to post] + +coverage: + round: down + precision: 5 + +ignore: + - "./tests/*" + - "./didier/cogs/*" # Cogs can't really be tested properly diff --git a/didier/cogs/currency.py b/didier/cogs/currency.py index 3283ed4..606a910 100644 --- a/didier/cogs/currency.py +++ b/didier/cogs/currency.py @@ -1,3 +1,5 @@ +import typing + import discord from discord.ext import commands @@ -5,6 +7,7 @@ from database.crud import currency as crud from database.exceptions.currency import DoubleNightly from didier import Didier from didier.utils.discord.checks import is_owner +from didier.utils.discord.converters import abbreviated_number from didier.utils.types.string import pluralize @@ -19,17 +22,19 @@ class Currency(commands.Cog): @commands.command(name="Award") @commands.check(is_owner) - async def award(self, ctx: commands.Context, user: discord.User, amount: int): + async def award(self, ctx: commands.Context, user: discord.User, amount: abbreviated_number): # type: ignore """Award a user a given amount of Didier Dinks""" + amount = typing.cast(int, amount) + async with self.client.db_session as session: await crud.add_dinks(session, user.id, amount) plural = pluralize("Didier Dink", amount) await ctx.reply( - f"**{ctx.author.display_name}** heeft **{user.display_name}** **{amount}** {plural} geschonken." + f"**{ctx.author.display_name}** heeft **{user.display_name}** **{amount}** {plural} geschonken.", + mention_author=False, ) - await self.client.confirm_message(ctx.message) - @commands.hybrid_command(name="bank") + @commands.hybrid_group(name="bank", case_insensitive=True, invoke_without_command=True) async def bank(self, ctx: commands.Context): """Show your Didier Bank information""" async with self.client.db_session as session: diff --git a/didier/utils/discord/converters/__init__.py b/didier/utils/discord/converters/__init__.py new file mode 100644 index 0000000..3f47753 --- /dev/null +++ b/didier/utils/discord/converters/__init__.py @@ -0,0 +1 @@ +from .numbers import * diff --git a/didier/utils/discord/converters/numbers.py b/didier/utils/discord/converters/numbers.py new file mode 100644 index 0000000..8019fa5 --- /dev/null +++ b/didier/utils/discord/converters/numbers.py @@ -0,0 +1,46 @@ +import math +from typing import Optional + + +__all__ = ["abbreviated_number"] + + +def abbreviated_number(argument: str) -> int: + """Custom converter to allow numbers to be abbreviated + Examples: + 515k + 4m + """ + if not argument: + raise ValueError + + if argument.isdecimal(): + return int(argument) + + units = {"k": 3, "m": 6, "b": 9, "t": 12} + + # Get the unit if there is one, then chop it off + value: Optional[int] = None + if not argument[-1].isdigit(): + if argument[-1].lower() not in units: + raise ValueError + + unit = argument[-1].lower() + value = units.get(unit) + argument = argument[:-1] + + # [int][unit] + if "." not in argument and value is not None: + return int(argument) * (10**value) + + # [float][unit] + if "." in argument: + # Floats themselves are not supported + if value is None: + raise ValueError + + as_float = float(argument) + return math.floor(as_float * (10**value)) + + # Unparseable + raise ValueError diff --git a/didier/utils/types/datetime.py b/didier/utils/types/datetime.py index 6e5c88e..73d9072 100644 --- a/didier/utils/types/datetime.py +++ b/didier/utils/types/datetime.py @@ -1,3 +1,3 @@ -def int_to_weekday(number: int) -> str: +def int_to_weekday(number: int) -> str: # pragma: no cover # it's useless to write a test for this """Get the Dutch name of a weekday from the number""" return ["Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag", "Zondag"][number] diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index 40b367b..0255877 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -1,9 +1,10 @@ +import math from typing import Optional def leading(character: str, string: str, target_length: Optional[int] = 2) -> str: """Add a leading [character] to [string] to make it length [target_length] - Pass None to target length to always do it, no matter the length + Pass None to target length to always do it (once), no matter the length """ # Cast to string just in case string = str(string) @@ -16,7 +17,7 @@ def leading(character: str, string: str, target_length: Optional[int] = 2) -> st if len(string) >= target_length: return string - frequency = (target_length - len(string)) // len(character) + frequency = math.ceil((target_length - len(string)) / len(character)) return (frequency * character) + string diff --git a/pyproject.toml b/pyproject.toml index 5263a7b..c97a7dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,22 @@ [tool.black] line-length = 120 +[tool.coverage.run] +concurrency = [ + "greenlet" +] +source = [ + "didier", + "database" +] +omit = [ + "./tests/*", + "./database/migrations.py", + "./didier/cogs/*", + "./didier/didier.py", + "./didier/data/*" +] + [tool.mypy] plugins = [ "sqlalchemy.ext.mypy.plugin" diff --git a/requirements-dev.txt b/requirements-dev.txt index aa657e0..646f5e6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ black==22.3.0 +coverage[toml]==6.4.1 mypy==0.961 pylint==2.14.1 pytest==7.1.2 diff --git a/tests/test_database/test_crud/test_ufora_announcements.py b/tests/test_database/test_crud/test_ufora_announcements.py index e69de29..ba6564a 100644 --- a/tests/test_database/test_crud/test_ufora_announcements.py +++ b/tests/test_database/test_crud/test_ufora_announcements.py @@ -0,0 +1,67 @@ +import datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from database.crud import ufora_announcements as crud +from database.models import UforaAnnouncement, UforaCourse + + +@pytest.fixture +async def course(database_session: AsyncSession) -> UforaCourse: + """Fixture to create a course""" + course = UforaCourse(name="test", code="code", year=1, log_announcements=True) + database_session.add(course) + await database_session.commit() + return course + + +@pytest.fixture +async def announcement(course: UforaCourse, database_session: AsyncSession) -> UforaAnnouncement: + """Fixture to create an announcement""" + announcement = UforaAnnouncement(course_id=course.course_id, publication_date=datetime.datetime.now()) + database_session.add(announcement) + await database_session.commit() + return announcement + + +async def test_get_courses_with_announcements_none(database_session: AsyncSession): + """Test getting all courses with announcements when there are none""" + results = await crud.get_courses_with_announcements(database_session) + assert len(results) == 0 + + +async def test_get_courses_with_announcements(database_session: AsyncSession): + """Test getting all courses with announcements""" + course_1 = UforaCourse(name="test", code="code", year=1, log_announcements=True) + course_2 = UforaCourse(name="test2", code="code2", year=1, log_announcements=False) + database_session.add_all([course_1, course_2]) + await database_session.commit() + + results = await crud.get_courses_with_announcements(database_session) + assert len(results) == 1 + assert results[0] == course_1 + + +async def test_create_new_announcement(course: UforaCourse, database_session: AsyncSession): + """Test creating a new announcement""" + await crud.create_new_announcement(database_session, 1, course=course, publication_date=datetime.datetime.now()) + await database_session.refresh(course) + assert len(course.announcements) == 1 + + +async def test_remove_old_announcements(announcement: UforaAnnouncement, database_session: AsyncSession): + """Test removing all stale announcements""" + course = announcement.course + announcement.publication_date -= datetime.timedelta(weeks=2) + announcement_2 = UforaAnnouncement(course_id=announcement.course_id, publication_date=datetime.datetime.now()) + database_session.add_all([announcement, announcement_2]) + await database_session.commit() + await database_session.refresh(course) + assert len(course.announcements) == 2 + + await crud.remove_old_announcements(database_session) + + await database_session.refresh(course) + assert len(course.announcements) == 1 + assert announcement_2.course.announcements[0] == announcement_2 diff --git a/tests/test_didier/test_utils/test_discord/test_converters/__init__.py b/tests/test_didier/test_utils/test_discord/test_converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_didier/test_utils/test_discord/test_converters/test_numbers.py b/tests/test_didier/test_utils/test_discord/test_converters/test_numbers.py new file mode 100644 index 0000000..3efc9f4 --- /dev/null +++ b/tests/test_didier/test_utils/test_discord/test_converters/test_numbers.py @@ -0,0 +1,50 @@ +import pytest + +from didier.utils.discord.converters import numbers + + +def test_abbreviated_int(): + """Test abbreviated_number for a regular int""" + assert numbers.abbreviated_number("500") == 500 + + +def test_abbreviated_float_errors(): + """Test abbreviated_number for a float""" + with pytest.raises(ValueError): + numbers.abbreviated_number("5.4") + + +def test_abbreviated_int_unit(): + """Test abbreviated_number for an int combined with a unit""" + assert numbers.abbreviated_number("20k") == 20000 + + +def test_abbreviated_int_unknown_unit(): + """Test abbreviated_number for an int combined with an unknown unit""" + with pytest.raises(ValueError): + numbers.abbreviated_number("20p") + + +def test_abbreviated_float_unit(): + """Test abbreviated_number for a float combined with a unit""" + assert numbers.abbreviated_number("20.5k") == 20500 + + +def test_abbreviated_float_unknown_unit(): + """Test abbreviated_number for a float combined with an unknown unit""" + with pytest.raises(ValueError): + numbers.abbreviated_number("20.5p") + + +def test_abbreviated_no_number(): + """Test abbreviated_number for unparseable content""" + with pytest.raises(ValueError): + numbers.abbreviated_number("didier") + + +def test_abbreviated_float_floors(): + """Test abbreviated_number for a float that is longer than the unit + Example: + 5.3k is 5300, but 5.3001k is 5300.1 + """ + assert numbers.abbreviated_number("5.3001k") == 5300 diff --git a/tests/test_didier/test_utils/test_types/__init__.py b/tests/test_didier/test_utils/test_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_didier/test_utils/test_types/test_string.py b/tests/test_didier/test_utils/test_types/test_string.py new file mode 100644 index 0000000..c3826df --- /dev/null +++ b/tests/test_didier/test_utils/test_types/test_string.py @@ -0,0 +1,22 @@ +from didier.utils.types.string import leading + + +def test_leading(): + """Test leading() when it actually does something""" + assert leading("0", "5") == "05" + assert leading("0", "5", target_length=3) == "005" + + +def test_leading_not_necessary(): + """Test leading() when the input is already long enough""" + assert leading("0", "05") == "05" + + +def test_leading_no_exact(): + """Test leading() when adding would bring you over the required length""" + assert leading("ab", "c", target_length=6) == "abababc" + + +def test_leading_no_target_length(): + """Test leading() when target_length is None""" + assert leading("0", "05", target_length=None) == "005"