mirror of
https://gitlab.com/animath/si/plateforme-corres2math.git
synced 2024-12-05 01:26:54 +00:00
Tests should not depend on Matrix-nio, that uses lxml that needs a lot of dependencies and a lot of time to build
This commit is contained in:
parent
1ddf39f296
commit
04dd02b88a
@ -6,7 +6,7 @@ py38:
|
|||||||
stage: test
|
stage: test
|
||||||
image: python:3.8-alpine
|
image: python:3.8-alpine
|
||||||
before_script:
|
before_script:
|
||||||
- apk add --no-cache gcc libc-dev libffi-dev libmagic libxml2-dev libxslt-dev libxml2-dev libxslt-dev
|
- apk add --no-cache libmagic
|
||||||
- pip install tox --no-cache-dir
|
- pip install tox --no-cache-dir
|
||||||
script: tox -e py38
|
script: tox -e py38
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ py39:
|
|||||||
stage: test
|
stage: test
|
||||||
image: python:3.9-alpine
|
image: python:3.9-alpine
|
||||||
before_script:
|
before_script:
|
||||||
- apk add --no-cache gcc libc-dev libffi-dev libmagic libxml2-dev libxslt-dev libxml2-dev libxslt-dev
|
- apk add --no-cache gcc libmagic
|
||||||
- pip install tox --no-cache-dir
|
- pip install tox --no-cache-dir
|
||||||
script: tox -e py39
|
script: tox -e py39
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ RUN apk add --no-cache bash
|
|||||||
RUN mkdir /code
|
RUN mkdir /code
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
COPY requirements.txt /code/requirements.txt
|
COPY requirements.txt /code/requirements.txt
|
||||||
RUN pip install -r requirements.txt psycopg2-binary sympasoap --no-cache-dir
|
RUN pip install -r requirements.txt --no-cache-dir
|
||||||
|
|
||||||
COPY . /code/
|
COPY . /code/
|
||||||
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from corres2math.matrix import Matrix, RoomVisibility, UploadError
|
from corres2math.matrix import Matrix, RoomPreset, RoomVisibility
|
||||||
from django.core.management import BaseCommand
|
from django.core.management import BaseCommand
|
||||||
from nio import RoomPreset
|
|
||||||
from registration.models import AdminRegistration, Registration
|
from registration.models import AdminRegistration, Registration
|
||||||
|
|
||||||
|
|
||||||
@ -11,11 +10,15 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
Matrix.set_display_name("Bot des Correspondances")
|
Matrix.set_display_name("Bot des Correspondances")
|
||||||
|
|
||||||
if not os.path.isfile(".matrix_avatar"): # pragma: no cover
|
if not os.getenv("SYNAPSE_PASSWORD"):
|
||||||
|
avatar_uri = "plop"
|
||||||
|
else: # pragma: no cover
|
||||||
|
if not os.path.isfile(".matrix_avatar"):
|
||||||
stat_file = os.stat("corres2math/static/logo.png")
|
stat_file = os.stat("corres2math/static/logo.png")
|
||||||
with open("corres2math/static/logo.png", "rb") as f:
|
with open("corres2math/static/logo.png", "rb") as f:
|
||||||
resp, _ = Matrix.upload(f, filename="logo.png", content_type="image/png", filesize=stat_file.st_size)
|
resp, _ = Matrix.upload(f, filename="logo.png", content_type="image/png",
|
||||||
if isinstance(resp, UploadError):
|
filesize=stat_file.st_size)
|
||||||
|
if not hasattr(resp, "content_uri"):
|
||||||
raise Exception(resp)
|
raise Exception(resp)
|
||||||
avatar_uri = resp.content_uri
|
avatar_uri = resp.content_uri
|
||||||
with open(".matrix_avatar", "w") as f:
|
with open(".matrix_avatar", "w") as f:
|
||||||
|
@ -2,7 +2,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from corres2math.lists import get_sympa_client
|
from corres2math.lists import get_sympa_client
|
||||||
from corres2math.matrix import Matrix
|
from corres2math.matrix import Matrix, RoomPreset, RoomVisibility
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -13,7 +13,6 @@ from django.utils import timezone
|
|||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.text import format_lazy
|
from django.utils.text import format_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from nio import RoomPreset, RoomVisibility
|
|
||||||
|
|
||||||
|
|
||||||
class Team(models.Model):
|
class Team(models.Model):
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -705,7 +704,6 @@ class TestStudentParticipation(TestCase):
|
|||||||
|
|
||||||
call_command('fix_matrix_channels')
|
call_command('fix_matrix_channels')
|
||||||
call_command('setup_third_phase')
|
call_command('setup_third_phase')
|
||||||
os.remove(".matrix_avatar")
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdmin(TestCase):
|
class TestAdmin(TestCase):
|
||||||
|
@ -12,7 +12,7 @@ from django.utils.encoding import force_bytes
|
|||||||
from django.utils.http import urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
|
||||||
from .auth import CustomAuthUser
|
from .auth import CustomAuthUser
|
||||||
from .models import AdminRegistration, CoachRegistration, Registration, StudentRegistration
|
from .models import AdminRegistration, CoachRegistration, StudentRegistration
|
||||||
|
|
||||||
|
|
||||||
class TestIndexPage(TestCase):
|
class TestIndexPage(TestCase):
|
||||||
|
@ -1,12 +1,7 @@
|
|||||||
|
from enum import Enum
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from nio import AsyncClient, DataProvider, ProfileSetAvatarError, ProfileSetAvatarResponse, \
|
|
||||||
ProfileSetDisplayNameError, ProfileSetDisplayNameResponse, RoomCreateError, RoomCreateResponse, \
|
|
||||||
RoomInviteError, RoomInviteResponse, RoomKickError, RoomKickResponse, RoomPreset, \
|
|
||||||
RoomPutStateError, RoomPutStateResponse, RoomResolveAliasResponse, RoomVisibility, TransferMonitor, \
|
|
||||||
UploadError, UploadResponse
|
|
||||||
|
|
||||||
|
|
||||||
class Matrix:
|
class Matrix:
|
||||||
@ -18,11 +13,11 @@ class Matrix:
|
|||||||
Tasks are normally asynchronous, but for compatibility we make
|
Tasks are normally asynchronous, but for compatibility we make
|
||||||
them synchronous.
|
them synchronous.
|
||||||
"""
|
"""
|
||||||
_token: str = None
|
_token = None
|
||||||
_device_id: str = None
|
_device_id = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _get_client(cls) -> Union[AsyncClient, "FakeMatrixClient"]: # pragma: no cover
|
async def _get_client(cls): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Retrieve the bot account.
|
Retrieve the bot account.
|
||||||
If not logged, log in and store access token.
|
If not logged, log in and store access token.
|
||||||
@ -30,6 +25,7 @@ class Matrix:
|
|||||||
if not os.getenv("SYNAPSE_PASSWORD"):
|
if not os.getenv("SYNAPSE_PASSWORD"):
|
||||||
return FakeMatrixClient()
|
return FakeMatrixClient()
|
||||||
|
|
||||||
|
from nio import AsyncClient
|
||||||
client = AsyncClient("https://correspondances-maths.fr", "@corres2mathbot:correspondances-maths.fr")
|
client = AsyncClient("https://correspondances-maths.fr", "@corres2mathbot:correspondances-maths.fr")
|
||||||
client.user_id = "@corres2mathbot:correspondances-maths.fr"
|
client.user_id = "@corres2mathbot:correspondances-maths.fr"
|
||||||
|
|
||||||
@ -53,7 +49,7 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_display_name(cls, name: str) -> Union[ProfileSetDisplayNameResponse, ProfileSetDisplayNameError]:
|
async def set_display_name(cls, name: str):
|
||||||
"""
|
"""
|
||||||
Set the display name of the bot account.
|
Set the display name of the bot account.
|
||||||
"""
|
"""
|
||||||
@ -62,7 +58,7 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_avatar(cls, avatar_url: str) -> Union[ProfileSetAvatarResponse, ProfileSetAvatarError]:
|
async def set_avatar(cls, avatar_url: str): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Set the display avatar of the bot account.
|
Set the display avatar of the bot account.
|
||||||
"""
|
"""
|
||||||
@ -73,13 +69,13 @@ class Matrix:
|
|||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def upload(
|
async def upload(
|
||||||
cls,
|
cls,
|
||||||
data_provider: DataProvider,
|
data_provider,
|
||||||
content_type: str = "application/octet-stream",
|
content_type: str = "application/octet-stream",
|
||||||
filename: Optional[str] = None,
|
filename: str = None,
|
||||||
encrypt: bool = False,
|
encrypt: bool = False,
|
||||||
monitor: Optional[TransferMonitor] = None,
|
monitor=None,
|
||||||
filesize: Optional[int] = None,
|
filesize: int = None,
|
||||||
) -> Tuple[Union[UploadResponse, UploadError], Optional[Dict[str, Any]]]:
|
): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
Upload a file to the content repository.
|
Upload a file to the content repository.
|
||||||
|
|
||||||
@ -134,24 +130,24 @@ class Matrix:
|
|||||||
"""
|
"""
|
||||||
client = await cls._get_client()
|
client = await cls._get_client()
|
||||||
return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize) \
|
return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize) \
|
||||||
if isinstance(client, AsyncClient) else UploadResponse("debug mode"), None
|
if not isinstance(client, FakeMatrixClient) else None, None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def create_room(
|
async def create_room(
|
||||||
cls,
|
cls,
|
||||||
visibility: RoomVisibility = RoomVisibility.private,
|
visibility=None,
|
||||||
alias: Optional[str] = None,
|
alias=None,
|
||||||
name: Optional[str] = None,
|
name=None,
|
||||||
topic: Optional[str] = None,
|
topic=None,
|
||||||
room_version: Optional[str] = None,
|
room_version=None,
|
||||||
federate: bool = True,
|
federate=True,
|
||||||
is_direct: bool = False,
|
is_direct=False,
|
||||||
preset: Optional[RoomPreset] = None,
|
preset=None,
|
||||||
invite=(),
|
invite=(),
|
||||||
initial_state=(),
|
initial_state=(),
|
||||||
power_level_override: Optional[Dict[str, Any]] = None,
|
power_level_override=None,
|
||||||
) -> Union[RoomCreateResponse, RoomCreateError]:
|
):
|
||||||
"""
|
"""
|
||||||
Create a new room.
|
Create a new room.
|
||||||
|
|
||||||
@ -213,18 +209,18 @@ class Matrix:
|
|||||||
power_level_override)
|
power_level_override)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def resolve_room_alias(cls, room_alias: str) -> Optional[str]:
|
async def resolve_room_alias(cls, room_alias: str):
|
||||||
"""
|
"""
|
||||||
Resolve a room alias to a room ID.
|
Resolve a room alias to a room ID.
|
||||||
Return None if the alias does not exist.
|
Return None if the alias does not exist.
|
||||||
"""
|
"""
|
||||||
client = await cls._get_client()
|
client = await cls._get_client()
|
||||||
resp: RoomResolveAliasResponse = await client.room_resolve_alias(room_alias)
|
resp = await client.room_resolve_alias(room_alias)
|
||||||
return resp.room_id if isinstance(resp, RoomResolveAliasResponse) else None
|
return resp.room_id if resp else None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def invite(cls, room_id: str, user_id: str) -> Union[RoomInviteResponse, RoomInviteError]:
|
async def invite(cls, room_id: str, user_id: str):
|
||||||
"""
|
"""
|
||||||
Invite a user to a room.
|
Invite a user to a room.
|
||||||
|
|
||||||
@ -266,13 +262,13 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def kick(cls, room_id: str, user_id: str, reason: str = None) -> Union[RoomKickResponse, RoomKickError]:
|
async def kick(cls, room_id: str, user_id: str, reason: str = None):
|
||||||
"""
|
"""
|
||||||
Kick a user from a room, or withdraw their invitation.
|
Kick a user from a room, or withdraw their invitation.
|
||||||
|
|
||||||
Kicking a user adjusts their membership to "leave" with an optional
|
Kicking a user adjusts their membership to "leave" with an optional
|
||||||
reason.
|
reason.
|
||||||
|
²
|
||||||
Returns either a `RoomKickResponse` if the request was successful or
|
Returns either a `RoomKickResponse` if the request was successful or
|
||||||
a `RoomKickError` if there was an error with the request.
|
a `RoomKickError` if there was an error with the request.
|
||||||
|
|
||||||
@ -289,8 +285,7 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int)\
|
async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int): # pragma: no cover
|
||||||
-> Union[RoomPutStateResponse, RoomPutStateError]: # pragma: no cover
|
|
||||||
"""
|
"""
|
||||||
Put a given power level to a user in a certain room.
|
Put a given power level to a user in a certain room.
|
||||||
|
|
||||||
@ -306,7 +301,7 @@ class Matrix:
|
|||||||
"""
|
"""
|
||||||
client = await cls._get_client()
|
client = await cls._get_client()
|
||||||
if isinstance(client, FakeMatrixClient):
|
if isinstance(client, FakeMatrixClient):
|
||||||
return RoomPutStateError("debug mode")
|
return None
|
||||||
|
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
@ -317,8 +312,7 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int)\
|
async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int): # pragma: no cover
|
||||||
-> Union[RoomPutStateResponse, RoomPutStateError]: # pragma: no cover
|
|
||||||
"""
|
"""
|
||||||
Define the minimal power level to have to send a certain event type
|
Define the minimal power level to have to send a certain event type
|
||||||
in a given room.
|
in a given room.
|
||||||
@ -335,7 +329,7 @@ class Matrix:
|
|||||||
"""
|
"""
|
||||||
client = await cls._get_client()
|
client = await cls._get_client()
|
||||||
if isinstance(client, FakeMatrixClient):
|
if isinstance(client, FakeMatrixClient):
|
||||||
return RoomPutStateError("debug mode")
|
return None
|
||||||
|
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
@ -349,8 +343,7 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_room_avatar(cls, room_id: str, avatar_uri: str)\
|
async def set_room_avatar(cls, room_id: str, avatar_uri: str):
|
||||||
-> Union[RoomPutStateResponse, RoomPutStateError]:
|
|
||||||
"""
|
"""
|
||||||
Define the avatar of a room.
|
Define the avatar of a room.
|
||||||
|
|
||||||
@ -370,6 +363,22 @@ class Matrix:
|
|||||||
}, state_key="")
|
}, state_key="")
|
||||||
|
|
||||||
|
|
||||||
|
if os.getenv("SYNAPSE_PASSWORD"): # pragma: no cover
|
||||||
|
from nio import RoomVisibility, RoomPreset
|
||||||
|
RoomVisibility = RoomVisibility
|
||||||
|
RoomPreset = RoomPreset
|
||||||
|
else:
|
||||||
|
# When running tests, faking matrix-nio classes to don't include the module
|
||||||
|
class RoomVisibility(Enum):
|
||||||
|
private = 'private'
|
||||||
|
public = 'public'
|
||||||
|
|
||||||
|
class RoomPreset(Enum):
|
||||||
|
private_chat = "private_chat"
|
||||||
|
trusted_private_chat = "trusted_private_chat"
|
||||||
|
public_chat = "public_chat"
|
||||||
|
|
||||||
|
|
||||||
class FakeMatrixClient:
|
class FakeMatrixClient:
|
||||||
"""
|
"""
|
||||||
Simulate a Matrix client to run tests, if no Matrix homeserver is connected.
|
Simulate a Matrix client to run tests, if no Matrix homeserver is connected.
|
||||||
|
@ -2,7 +2,6 @@ from threading import local
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AnonymousUser, User
|
from django.contrib.auth.models import AnonymousUser, User
|
||||||
from django.contrib.sessions.backends.db import SessionStore
|
|
||||||
|
|
||||||
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
|
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
|
||||||
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session')
|
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session')
|
||||||
|
@ -51,16 +51,14 @@ INSTALLED_APPS = [
|
|||||||
'django.forms',
|
'django.forms',
|
||||||
|
|
||||||
'bootstrap_datepicker_plus',
|
'bootstrap_datepicker_plus',
|
||||||
|
'cas_server',
|
||||||
'crispy_forms',
|
'crispy_forms',
|
||||||
'django_extensions',
|
|
||||||
'django_tables2',
|
'django_tables2',
|
||||||
'haystack',
|
'haystack',
|
||||||
'logs',
|
'logs',
|
||||||
'mailer',
|
|
||||||
'polymorphic',
|
'polymorphic',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
'cas_server',
|
|
||||||
|
|
||||||
'api',
|
'api',
|
||||||
'eastereggs',
|
'eastereggs',
|
||||||
@ -68,6 +66,12 @@ INSTALLED_APPS = [
|
|||||||
'participation',
|
'participation',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if "test" not in sys.argv: # pragma: no cover
|
||||||
|
INSTALLED_APPS += [
|
||||||
|
'django_extensions',
|
||||||
|
'mailer',
|
||||||
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
Django~=3.0
|
Django~=3.1
|
||||||
django-bootstrap-datepicker-plus
|
django-bootstrap-datepicker-plus~=3.0
|
||||||
django-cas-server
|
django-cas-server~=1.2
|
||||||
django-crispy-forms
|
django-crispy-forms~=1.9
|
||||||
django-extensions
|
django-extensions~=3.0
|
||||||
django-filter~=2.3.0
|
django-filter~=2.3
|
||||||
django-haystack~=3.0
|
django-haystack~=3.0
|
||||||
django-mailer
|
django-mailer~=2.0
|
||||||
django-polymorphic
|
django-polymorphic~=3.0
|
||||||
django-tables2
|
django-tables2~=2.3
|
||||||
djangorestframework~=3.11.1
|
djangorestframework~=3.12
|
||||||
django-rest-polymorphic
|
django-rest-polymorphic~=0.1
|
||||||
matrix-nio
|
gunicorn~=20.0
|
||||||
ptpython
|
matrix-nio~=0.15
|
||||||
python-magic~=0.4.18
|
psycopg2-binary~=2.8
|
||||||
gunicorn
|
ptpython~=3.0
|
||||||
whoosh
|
python-magic~=0.4
|
||||||
|
sympasoap~=1.0
|
||||||
|
whoosh~=2.7
|
15
tox.ini
15
tox.ini
@ -7,10 +7,21 @@ envlist =
|
|||||||
skipsdist = True
|
skipsdist = True
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
sitepackages = True
|
sitepackages = False
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements.txt
|
|
||||||
coverage
|
coverage
|
||||||
|
Django~=3.1
|
||||||
|
django-bootstrap-datepicker-plus~=3.0
|
||||||
|
django-cas-server~=1.2
|
||||||
|
django-crispy-forms~=1.9
|
||||||
|
django-filter~=2.3
|
||||||
|
django-haystack~=3.0
|
||||||
|
django-polymorphic~=3.0
|
||||||
|
django-tables2~=2.3
|
||||||
|
djangorestframework~=3.12
|
||||||
|
django-rest-polymorphic~=0.1
|
||||||
|
python-magic~=0.4
|
||||||
|
whoosh~=2.7
|
||||||
commands =
|
commands =
|
||||||
coverage run --source=apps,corres2math ./manage.py test apps/ corres2math/
|
coverage run --source=apps,corres2math ./manage.py test apps/ corres2math/
|
||||||
coverage report -m
|
coverage report -m
|
||||||
|
Loading…
Reference in New Issue
Block a user