From 9ed846c78b89ddf6000f00028d9063b8847779db Mon Sep 17 00:00:00 2001 From: gzbender Date: Wed, 30 Jan 2019 10:38:39 +0500 Subject: [PATCH 1/8] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D1=8C?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20-=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/course/tests.py | 3 --- apps/course/tests/__init__.py | 0 apps/course/tests/test_views.py | 28 ++++++++++++++++++++++++++++ project/tests/__init__.py | 0 project/tests/factories.py | 24 ++++++++++++++++++++++++ requirements.txt | 1 + 6 files changed, 53 insertions(+), 3 deletions(-) delete mode 100644 apps/course/tests.py create mode 100644 apps/course/tests/__init__.py create mode 100644 apps/course/tests/test_views.py create mode 100644 project/tests/__init__.py create mode 100644 project/tests/factories.py diff --git a/apps/course/tests.py b/apps/course/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/apps/course/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/course/tests/__init__.py b/apps/course/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/course/tests/test_views.py b/apps/course/tests/test_views.py new file mode 100644 index 00000000..29745bbb --- /dev/null +++ b/apps/course/tests/test_views.py @@ -0,0 +1,28 @@ +from django.test import TestCase +from django_faker import Faker +from django.core.urlresolvers import reverse + +from apps.course.models import Course +from project.tests.factories import * + + +class CoursesTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + for i in range(10): + CourseFactory() + + def test_courses_url_accessible(self): + resp = self.client.get(reverse('courses')) + self.assertEqual(resp.status_code, 200) + + def test_course_url_accessible(self): + course = Course.objects.all()[:1][0] + resp = self.client.get(course.url) + self.assertEqual(resp.status_code, 200) + + def test_course_edit_url_accessible(self): + course = Course.objects.all()[:1][0] + resp = self.client.get(course.url) + self.assertEqual(resp.status_code, 200) diff --git a/project/tests/__init__.py b/project/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project/tests/factories.py b/project/tests/factories.py new file mode 100644 index 00000000..8ab316c9 --- /dev/null +++ b/project/tests/factories.py @@ -0,0 +1,24 @@ +from factory.django import DjangoModelFactory, SubFactory + +from apps.user.models import * +from apps.course.models import * + + +class UserFactory(DjangoModelFactory): + class Meta: + model = User + + +class CategoryFactory(DjangoModelFactory): + class Meta: + model = Category + + +class CourseFactory(DjangoModelFactory): + class Meta: + model = Course + + author = SubFactory(UserFactory) + category = SubFactory(CategoryFactory) + cover = None + gallery = None diff --git a/requirements.txt b/requirements.txt index 78736651..b0841a51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,4 @@ sendgrid drf_dynamic_fields flower==0.9.2 unidecode +factory-boy==2.11.1 From e0d3caff053681a0864bf3aa5bae31e828a44f24 Mon Sep 17 00:00:00 2001 From: gzbender Date: Mon, 4 Feb 2019 20:31:46 +0500 Subject: [PATCH 2/8] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D1=8C?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20-=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0022_auto_20180208_0647.py | 1 + apps/course/tests/test_views.py | 44 +++++++--- project/tests/factories.py | 81 +++++++++++++++++-- project/utils/selenium_utils.py | 50 ++++++++++++ 4 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 project/utils/selenium_utils.py diff --git a/apps/course/migrations/0022_auto_20180208_0647.py b/apps/course/migrations/0022_auto_20180208_0647.py index c95cd3fc..54fc0991 100644 --- a/apps/course/migrations/0022_auto_20180208_0647.py +++ b/apps/course/migrations/0022_auto_20180208_0647.py @@ -8,6 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('course', '0021_auto_20180206_0632'), + ('content', '0005_auto_20180208_0520'), ] operations = [ diff --git a/apps/course/tests/test_views.py b/apps/course/tests/test_views.py index 29745bbb..29a48fb9 100644 --- a/apps/course/tests/test_views.py +++ b/apps/course/tests/test_views.py @@ -1,28 +1,50 @@ +from datetime import timedelta +from random import randint + +from selenium import webdriver +from django.utils import timezone from django.test import TestCase -from django_faker import Faker -from django.core.urlresolvers import reverse +from django.shortcuts import reverse -from apps.course.models import Course from project.tests.factories import * +from project.utils.selenium_utils import SeleniumExtensions as SE class CoursesTestCase(TestCase): @classmethod def setUpTestData(cls): - for i in range(10): - CourseFactory() + create_admin() + create_batch_unique(CourseFactory, status=Course.STATUS_CHOICES[:3], price=[0, 1000], + age=Course.AGE_CHOICES[:2], deferred_start_at=[None, timezone.now() + timedelta(days=randint(5, 15))]) def test_courses_url_accessible(self): + print('get ', reverse('courses')) resp = self.client.get(reverse('courses')) self.assertEqual(resp.status_code, 200) def test_course_url_accessible(self): - course = Course.objects.all()[:1][0] - resp = self.client.get(course.url) - self.assertEqual(resp.status_code, 200) + for course in Course.objects.filter(status=Course.PUBLISHED): + print('get ', course.url) + resp = self.client.get(course.url) + self.assertEqual(resp.status_code, 200) + @login_admin def test_course_edit_url_accessible(self): - course = Course.objects.all()[:1][0] - resp = self.client.get(course.url) - self.assertEqual(resp.status_code, 200) + for course in Course.objects.all(): + print('get ', reverse('course_edit', args=[course.id])) + resp = self.client.get(reverse('course_edit', args=[course.id])) + self.assertEqual(resp.status_code, 200) + + +class CourseEditTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.driver = webdriver.Chrome() + create_admin() + create_batch_unique(CourseFactory, status=Course.STATUS_CHOICES[:3], price=[0, 1000], + age=Course.AGE_CHOICES[:2], deferred_start_at=[None, timezone.now() + timedelta(days=randint(5, 15))]) + + def test_course_edit(self): + print('Course.objects.all().count()', Course.objects.all().count()) diff --git a/project/tests/factories.py b/project/tests/factories.py index 8ab316c9..5c7cd5da 100644 --- a/project/tests/factories.py +++ b/project/tests/factories.py @@ -1,13 +1,81 @@ -from factory.django import DjangoModelFactory, SubFactory +from itertools import combinations + +from factory.django import DjangoModelFactory, ImageField +import factory +import factory.fuzzy +from unidecode import unidecode +from django.utils.text import slugify + -from apps.user.models import * from apps.course.models import * +from apps.content.models import * +from apps.user.models import * + + +ADMIN_EMAIL = 'admin@mail.com' + + +def create_admin(): + admin = UserFactory(username=ADMIN_EMAIL, email=ADMIN_EMAIL, role=User.ADMIN_ROLE, + is_staff=True, is_superuser=True) + admin.set_password('admin') + admin.save() + return admin + + +def create_users(count_multiplier=1): + create_admin() + UserFactory.create_batch(10 * count_multiplier, role=User.USER_ROLE) + UserFactory.create_batch(5 * count_multiplier, role=User.AUTHOR_ROLE) + UserFactory.create_batch(5 * count_multiplier, role=User.TEACHER_ROLE) + UserFactory.create_batch(5, role=User.ADMIN_ROLE, is_staff=True) + + +def login_admin(fn): + def wrap(self, *args, **kwargs): + admin = User.objects.get(username=ADMIN_EMAIL) + self.client.force_login(admin) + try: + fn(self, *args, **kwargs) + finally: + self.client.logout() + return wrap + + +def create_batch_unique(factory_class, **kwargs): + model = factory_class._meta.model + values = [] + for k, v in kwargs.items(): + try: + f = model._meta.get_field(k) + except: + del kwargs[k] + if getattr(f, 'choices', None): + v = [c[0] for c in v] + values += ((k, val) for val in v) + for params in combinations(values, len(kwargs)): + data = dict(params) + if len(data) == len(kwargs): + factory_class(**data) + + +class ImageObjectFactory(DjangoModelFactory): + image = ImageField() + image_thumbnail = ImageField() class UserFactory(DjangoModelFactory): class Meta: model = User + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name') + email = factory.Sequence(lambda n: "test_user%d@mail.com" % n) + username = factory.LazyAttribute(lambda o: o.email) + gallery = None + photo = None #factory.SubFactory(ImageObjectFactory) + is_active = True + class CategoryFactory(DjangoModelFactory): class Meta: @@ -18,7 +86,10 @@ class CourseFactory(DjangoModelFactory): class Meta: model = Course - author = SubFactory(UserFactory) - category = SubFactory(CategoryFactory) - cover = None + author = factory.SubFactory(UserFactory, role=User.AUTHOR_ROLE) + title = factory.Faker('sentence', nb_words=6) + slug = factory.LazyAttribute(lambda o: slugify(unidecode(o.title[:90]))) + category = factory.SubFactory(CategoryFactory) + cover = None #factory.SubFactory(ImageObjectFactory) gallery = None + status = factory.Iterator([s[0] for s in Course.STATUS_CHOICES]) diff --git a/project/utils/selenium_utils.py b/project/utils/selenium_utils.py new file mode 100644 index 00000000..0b7f47cf --- /dev/null +++ b/project/utils/selenium_utils.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +class elem_in(object): + default_find_fn = 'find_element_by_xpath' + def __init__(self, element, xpath, find_fn=None): + self.element = element + self.xpath = xpath + self.find_fn = self.default_find_fn if find_fn is None else find_fn + + def __call__(self, driver): + try: + return getattr(self.element, self.find_fn)(self.xpath) + except: + return False + +class elems_in(elem_in): + default_find_fn = 'find_elements_by_xpath' + + +class SeleniumExtensions(object): + + @classmethod + def wait_elem(cls, driver, xpath, inside_el=None, wait_time=10): + if inside_el: + return WebDriverWait(driver, wait_time).until(elem_in(inside_el, xpath)) + if xpath[:2] != '//': + raise Exception('XPath in wait_elem must start with //') + return WebDriverWait(driver, wait_time).until( + EC.presence_of_element_located( + (By.XPATH, xpath) + ) + ) + + @classmethod + def wait_elems(cls, driver, xpath, inside_el=None, wait_time=10): + if inside_el: + return WebDriverWait(driver, wait_time).until(elems_in(inside_el, xpath)) + if xpath[:2] != '//': + raise Exception('XPath in wait_elems must start with //') + return WebDriverWait(driver, wait_time).until( + EC.presence_of_all_elements_located( + (By.XPATH, xpath) + ) + ) \ No newline at end of file From 15954fc66aa53f0402748e0c6685a32334ccd73e Mon Sep 17 00:00:00 2001 From: gzbender Date: Wed, 6 Feb 2019 18:00:50 +0500 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D1=8C?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20-=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/course/tests/test_views.py | 85 ++++++++++++++++++++++-- project/tests/__init__.py | 44 ++++++++++++ project/utils/selenium_utils.py | 96 +++++++++++++-------------- requirements.txt | 2 + web/src/components/CourseRedactor.vue | 71 +++++++++++--------- 5 files changed, 212 insertions(+), 86 deletions(-) diff --git a/apps/course/tests/test_views.py b/apps/course/tests/test_views.py index 29a48fb9..c1305ad2 100644 --- a/apps/course/tests/test_views.py +++ b/apps/course/tests/test_views.py @@ -2,14 +2,19 @@ from datetime import timedelta from random import randint from selenium import webdriver +from factory.faker import Faker from django.utils import timezone from django.test import TestCase from django.shortcuts import reverse +from django.conf import settings +from django.utils.text import slugify +from unidecode import unidecode from project.tests.factories import * +from project.tests import SeleniumTestCase from project.utils.selenium_utils import SeleniumExtensions as SE - +''' class CoursesTestCase(TestCase): @classmethod @@ -18,6 +23,11 @@ class CoursesTestCase(TestCase): create_batch_unique(CourseFactory, status=Course.STATUS_CHOICES[:3], price=[0, 1000], age=Course.AGE_CHOICES[:2], deferred_start_at=[None, timezone.now() + timedelta(days=randint(5, 15))]) + @classmethod + def tearDownClass(cls): + print('teardown CoursesTestCase') + super().tearDownClass() + def test_courses_url_accessible(self): print('get ', reverse('courses')) resp = self.client.get(reverse('courses')) @@ -35,16 +45,79 @@ class CoursesTestCase(TestCase): print('get ', reverse('course_edit', args=[course.id])) resp = self.client.get(reverse('course_edit', args=[course.id])) self.assertEqual(resp.status_code, 200) +''' -class CourseEditTestCase(TestCase): +class CourseEditTestCase(SeleniumTestCase): @classmethod def setUpTestData(cls): - cls.driver = webdriver.Chrome() create_admin() - create_batch_unique(CourseFactory, status=Course.STATUS_CHOICES[:3], price=[0, 1000], - age=Course.AGE_CHOICES[:2], deferred_start_at=[None, timezone.now() + timedelta(days=randint(5, 15))]) + UserFactory.create_batch(5, role=User.AUTHOR_ROLE) + # create_batch_unique(CourseFactory, status=Course.STATUS_CHOICES[:3], price=[0, 1000], + # age=Course.AGE_CHOICES[:2], deferred_start_at=[None, timezone.now() + timedelta(days=randint(5, 15))]) + + @classmethod + def tearDownClass(cls): + print('teardown CourseEditTestCase') + super().tearDownClass() def test_course_edit(self): - print('Course.objects.all().count()', Course.objects.all().count()) + print('go to google') + self.driver.get('http://www.google.com') + return + + user = User.objects.filter(role=User.AUTHOR_ROLE).first() + self.client.force_login(user) + url = self.get_url(reverse('course_create')) + print('url', url) + self.driver.get(url) + # visible always elements + title_el = self.wait_elem_name('course-title') + slug_el = self.wait_elem_name('course-slug') + category_el = self.wait_elem_name('course-category') + is_paid_no_el = self.wait_elem_name('course-is-paid-no') + is_paid_yes_el = self.wait_elem_name('course-is-paid-yes') + is_paid_input_el = self.wait_elem_name('course-is-paid-input') + age_el = self.wait_elem_name('course-age') + is_featured_el = self.wait_elem_name('course-is-featured') + is_deferred_no_el = self.wait_elem_name('course-is-deferred-no') + is_deferred_yes_el = self.wait_elem_name('course-is-deferred-yes') + is_deferred_input_el = self.wait_elem_name('course-is-deferred-input') + content_btn_el = self.wait_elem_name('course-content-btn') + lessons_btn_el = self.wait_elem_name('course-lessons-btn') + content_el = self.wait_elem_name('course-content') + + title = Faker('sentence', nb_words=6) + title_el.send_keys(title) + slug = slugify(unidecode(title[:90])) + print('title', title, 'slug', slug) + self.assertEqual(slug_el.text, slug) + + print("is_paid_input_el.get_attribute('checked')", is_paid_input_el.get_attribute('checked')) + self.assertFalse(is_paid_input_el.get_attribute('checked')) + self.assertRaises(callable=lambda: self.driver.find_element_by_name('course-price')) + self.assertRaises(callable=lambda: self.driver.find_element_by_name('course-old-price')) + is_paid_yes_el.click() + try: + price_el = self.wait_elem_name('course-price') + old_price_el = self.wait_elem_name('course-old-price') + except: + self.fail('Price and old price elements not shown') + + self.assertFalse(is_deferred_input_el.get_attribute('checked')) + self.assertRaises(callable=lambda: self.driver.find_element_by_name('course-date')) + self.assertRaises(callable=lambda: self.driver.find_element_by_name('course-time')) + is_deferred_yes_el.click() + try: + date_el = self.wait_elem_name('course-date') + time_el = self.wait_elem_name('course-time') + except: + self.fail('Date and time elements not shown') + + # lessons_el = self.wait_elem_name('course-lessons') + # add_lesson_el = self.wait_elem_name('course-add-lesson') + # lesson_edit_el = self.wait_elem_name('course-lesson-edit') + # stream_el = self.wait_elem_name('course-stream') + + diff --git a/project/tests/__init__.py b/project/tests/__init__.py index e69de29b..b2548d50 100644 --- a/project/tests/__init__.py +++ b/project/tests/__init__.py @@ -0,0 +1,44 @@ +from django.test import TestCase +from selenium import webdriver +from selenium.webdriver.common.by import By +from pyvirtualdisplay import Display + +from project.utils.selenium_utils import SeleniumExtensions as SE +from django.conf import settings + + +class SeleniumTestCase(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.display = Display(visible=0, size=(1280, 1024)) + cls.display.start() + cls.driver = webdriver.Chrome() + + @classmethod + def tearDownClass(cls): + cls.driver.close() + cls.display.stop() + super().tearDownClass() + + def get_url(self, url): + return 'http://%s%s' % (settings.MAIN_HOST, url) + + def wait_elem_xpath(self, xpath, inside_el=None, wait_time=10): + return SE.wait_elem(self.driver, (By.XPATH, xpath), inside_el, wait_time) + + def wait_elems_xpath(self, xpath, inside_el=None, wait_time=10): + return SE.wait_elems(self.driver, (By.XPATH, xpath), inside_el, wait_time) + + def wait_elem_css(self, selector, inside_el=None, wait_time=10): + return SE.wait_elem(self.driver, (By.CSS_SELECTOR, selector), inside_el, wait_time) + + def wait_elems_css(self, selector, inside_el=None, wait_time=10): + return SE.wait_elems(self.driver, (By.CSS_SELECTOR, selector), inside_el, wait_time) + + def wait_elem_name(self, name, inside_el=None, wait_time=10): + return SE.wait_elem(self.driver, (By.NAME, name), inside_el, wait_time) + + def wait_elems_name(self, name, inside_el=None, wait_time=10): + return SE.wait_elems(self.driver, (By.NAME, name), inside_el, wait_time) diff --git a/project/utils/selenium_utils.py b/project/utils/selenium_utils.py index 0b7f47cf..5bf62c6b 100644 --- a/project/utils/selenium_utils.py +++ b/project/utils/selenium_utils.py @@ -1,50 +1,46 @@ -# -*- coding: utf-8 -*- - -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC - - -class elem_in(object): - default_find_fn = 'find_element_by_xpath' - def __init__(self, element, xpath, find_fn=None): - self.element = element - self.xpath = xpath - self.find_fn = self.default_find_fn if find_fn is None else find_fn - - def __call__(self, driver): - try: - return getattr(self.element, self.find_fn)(self.xpath) - except: - return False - -class elems_in(elem_in): - default_find_fn = 'find_elements_by_xpath' - - -class SeleniumExtensions(object): - - @classmethod - def wait_elem(cls, driver, xpath, inside_el=None, wait_time=10): - if inside_el: - return WebDriverWait(driver, wait_time).until(elem_in(inside_el, xpath)) - if xpath[:2] != '//': - raise Exception('XPath in wait_elem must start with //') - return WebDriverWait(driver, wait_time).until( - EC.presence_of_element_located( - (By.XPATH, xpath) - ) - ) - - @classmethod - def wait_elems(cls, driver, xpath, inside_el=None, wait_time=10): - if inside_el: - return WebDriverWait(driver, wait_time).until(elems_in(inside_el, xpath)) - if xpath[:2] != '//': - raise Exception('XPath in wait_elems must start with //') - return WebDriverWait(driver, wait_time).until( - EC.presence_of_all_elements_located( - (By.XPATH, xpath) - ) - ) \ No newline at end of file +# -*- coding: utf-8 -*- + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +class ElementIn(object): + + default_find_fn = 'find_element' + + def __init__(self, element, locator, find_fn=None): + self.element = element + self.locator = locator + self.find_fn = self.default_find_fn if find_fn is None else find_fn + + def __call__(self, driver): + try: + return getattr(self.element, self.find_fn)(self.locator) + except: + return False + + +class ElementsIn(ElementIn): + + default_find_fn = 'find_elements' + + +class SeleniumExtensions(object): + + @classmethod + def wait_elem(cls, driver, locator, inside_el=None, wait_time=10): + if inside_el: + return WebDriverWait(driver, wait_time).until(ElementIn(inside_el, locator)) + return WebDriverWait(driver, wait_time).until( + EC.presence_of_element_located(locator) + ) + + @classmethod + def wait_elems(cls, driver, locator, inside_el=None, wait_time=10): + if inside_el: + return WebDriverWait(driver, wait_time).until(ElementsIn(inside_el, locator)) + return WebDriverWait(driver, wait_time).until( + EC.presence_of_all_elements_located(locator) + ) diff --git a/requirements.txt b/requirements.txt index b0841a51..4dac0884 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,5 @@ drf_dynamic_fields flower==0.9.2 unidecode factory-boy==2.11.1 +pyvirtualdisplay==0.2.1 # + sudo apt-get install xvfb + sudo apt-get install chromium-chromedriver +# + sudo ln -s /usr/lib/chromium-browser/chromedriver /usr/bin/chromedriver diff --git a/web/src/components/CourseRedactor.vue b/web/src/components/CourseRedactor.vue index 073e9f49..79a1ed67 100644 --- a/web/src/components/CourseRedactor.vue +++ b/web/src/components/CourseRedactor.vue @@ -6,12 +6,13 @@ @@ -94,27 +97,29 @@
СТОИМОСТЬ
- +
СТОИМОСТЬ БЕЗ СКИДКИ
- +
ВОЗРАСТ
-
@@ -122,12 +127,14 @@
ЗАПУСК
@@ -135,13 +142,15 @@
ДАТА
- +
ВРЕМЯ
- +
@@ -153,12 +162,12 @@
- -
- +