improve bazdid 4
This commit is contained in:
1 parent
f86d060e4d
commit
8fd881e0ea
10 files changed
+1752
-320
No files matched your search
@@ -9,6 +9,8 @@
|
||||
هیچ پاسخی نباید کد ۵۰۰ داشته باشد.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -257,3 +259,114 @@ class ViewSmokeTests(TestCase):
|
||||
f"دادهاند: {failures}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _DivBalanceParser(HTMLParser):
|
||||
"""شمارندهٔ عمق <div> با احترام به محتوای <script>/<style>.
|
||||
|
||||
نکته: HTMLParser محتوای script/style را متن خام میداند، پس `<div>` داخل
|
||||
رشتهٔ جاوااسکریپت بهاشتباه شمرده نمیشود — همان چیزی که شمارش متنی ساده
|
||||
اشتباه میکند.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.depth = 0
|
||||
self.min_depth = 0
|
||||
self.depth_at_body = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'div':
|
||||
self.depth += 1
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'div':
|
||||
self.depth -= 1
|
||||
self.min_depth = min(self.min_depth, self.depth)
|
||||
elif tag == 'body':
|
||||
self.depth_at_body = self.depth
|
||||
|
||||
|
||||
class BaseTemplateStructureTests(TestCase):
|
||||
"""ساختار HTML قالب پایه.
|
||||
|
||||
چرا؟ قالب `base.html` یک `<div class="">` باز میکرد که هرگز بسته نمیشد.
|
||||
مرورگر آن را در EOF خودکار میبست، پس هیچ خطایی دیده نمیشد؛ ولی همهٔ
|
||||
صفحات HTML نامعتبر تولید میکردند و بازسازی DOM توسط جاوااسکریپت و
|
||||
پارسرهای سختگیر میتوانست بشکند.
|
||||
"""
|
||||
|
||||
URLS = ['/panel/', '/panel/customers/', '/panel/entezar/', '/panel/rezayat/',
|
||||
'/panel/hesabdari/', '/panel/report/']
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('structureAdmin', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _parsed_pages(self):
|
||||
"""صفحاتی که در محیط تست کد ۲۰۰ میدهند را پارس میکند.
|
||||
|
||||
برخی صفحات روی دیتابیس خالی خطای داخلی میدهند (باگ مستقل، پوششدادهشده
|
||||
در تست دودی)؛ آنها اینجا کنار گذاشته میشوند. برای اینکه تست بیاثر
|
||||
نشود، تعداد صفحات بررسیشده assert میشود.
|
||||
"""
|
||||
parsed = []
|
||||
for url in self.URLS:
|
||||
response = self.client.get(url)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
parser = _DivBalanceParser()
|
||||
parser.feed(response.content.decode('utf-8', 'replace'))
|
||||
parsed.append((url, parser))
|
||||
return parsed
|
||||
|
||||
def test_every_page_has_balanced_divs(self):
|
||||
"""در پایان سند، هیچ <div> بازی نباید باقی بماند."""
|
||||
parsed = self._parsed_pages()
|
||||
self.assertGreaterEqual(len(parsed), 5,
|
||||
msg='صفحات کافی برای بررسی ساختار رندر نشدند')
|
||||
for url, parser in parsed:
|
||||
with self.subTest(url=url):
|
||||
self.assertEqual(
|
||||
parser.depth_at_body, 0,
|
||||
msg='در %s هنگام </body> تعداد %d تگ <div> بستهنشده ماند'
|
||||
% (url, parser.depth_at_body),
|
||||
)
|
||||
|
||||
def test_no_stray_closing_div(self):
|
||||
"""عمق هرگز نباید منفی شود؛ یعنی </div> اضافی هم وجود ندارد."""
|
||||
parsed = self._parsed_pages()
|
||||
self.assertGreaterEqual(len(parsed), 5)
|
||||
for url, parser in parsed:
|
||||
with self.subTest(url=url):
|
||||
self.assertGreaterEqual(parser.min_depth, 0,
|
||||
msg='در %s تگ </div> اضافی وجود دارد' % url)
|
||||
|
||||
def test_base_template_comments_are_single_line(self):
|
||||
"""کامنتهای {# ... #} باید تکخطی باشند.
|
||||
|
||||
موتور قالب جنگو (۳.۲) الگوی کامنت را بدون re.DOTALL میشناسد؛ کامنت
|
||||
چندخطی حذف نمیشود و عیناً در HTML چاپ میشود — در همین اصلاح، یک
|
||||
`<div class="">` داخل کامنت چندخطی، بهعنوان تگ واقعی به خروجی اضافه
|
||||
شده بود و تگ بستن را میبلعید.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
|
||||
templates_root = os.path.join('admin_panel', 'templates')
|
||||
offenders = []
|
||||
for base, _dirs, files in os.walk(templates_root):
|
||||
for name in files:
|
||||
if not name.endswith('.html'):
|
||||
continue
|
||||
path = os.path.join(base, name)
|
||||
text = io.open(path, encoding='utf-8', errors='replace').read()
|
||||
for match in re.finditer(r'\{#', text):
|
||||
tail = text[match.start():match.start() + 500]
|
||||
line_end = tail.find('\n')
|
||||
end = line_end if line_end > -1 else len(tail)
|
||||
if tail.find('#}', 0, end) == -1:
|
||||
offenders.append('%s:%d' % (
|
||||
path, text[:match.start()].count('\n') + 1))
|
||||
self.assertEqual(offenders, [],
|
||||
msg='کامنت چندخطی در این قالبها حذف نمیشود: %s' % offenders)
|
||||
+543
-29
@@ -24,6 +24,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from admin_panel.models import Action, Customer, PhoneNumber
|
||||
from admin_panel.views.customers import ENTEZAR_PAGE_SIZE
|
||||
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
|
||||
last_visit_map, normalize_hold_until,
|
||||
refresh_last_bazdid, set_visit_hold,
|
||||
@@ -329,7 +330,9 @@ class EntezarScannerConsistencyTests(TestCase):
|
||||
|
||||
def _entezar_ids(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get('/panel/entezar/')
|
||||
# نمای صریح «تعیین تکلیف نشده» — همان چیزی که اسکنر پیامک با آن
|
||||
# همتراز است. (پیشفرض صفحه اکنون «نیازمند پیگیری امروز» است.)
|
||||
response = self.client.get('/panel/entezar/?view=uncoordinated')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return {c.id for c in response.context['customers']}
|
||||
|
||||
@@ -607,7 +610,8 @@ class NextAppointmentTests(TestCase):
|
||||
return customer
|
||||
|
||||
def _entezar_ids(self):
|
||||
response = self.client.get('/panel/entezar/')
|
||||
# نمای صریح «تعیین تکلیف نشده» (پیشفرض صفحه اکنون «پیگیری امروز» است).
|
||||
response = self.client.get('/panel/entezar/?view=uncoordinated')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return {c.id for c in response.context['customers']}
|
||||
|
||||
@@ -783,13 +787,18 @@ class VisitHoldTests(TestCase):
|
||||
customer.refresh_from_db()
|
||||
return customer
|
||||
|
||||
def _entezar(self, view=''):
|
||||
def _entezar(self, view='uncoordinated'):
|
||||
"""پاسخ صفحه؛ پیشفرض این کلاس «تعیین تکلیف نشده» است.
|
||||
|
||||
(پیشفرض خودِ صفحه اکنون «نیازمند پیگیری امروز» است و در
|
||||
``TodayFollowupTests`` جداگانه پوشش داده میشود.)
|
||||
"""
|
||||
url = '/panel/entezar/' + (f'?view={view}' if view else '')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
def _ids(self, view=''):
|
||||
def _ids(self, view='uncoordinated'):
|
||||
return {c.id for c in self._entezar(view).context['customers']}
|
||||
|
||||
def _post_action(self, customer, **payload):
|
||||
@@ -1141,8 +1150,11 @@ class VisitHoldTests(TestCase):
|
||||
|
||||
def test_invalid_view_falls_back_to_default(self):
|
||||
a = self._customer('فقط الف')
|
||||
self.assertEqual(self._ids('nonsense'), {a.pk})
|
||||
self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated')
|
||||
# پیشفرض صفحه «نیازمند پیگیری امروز» است؛ مشتری بدون موعد در آن نیست،
|
||||
# ولی در نمای «تعیین تکلیف نشده» دیده میشود.
|
||||
self.assertEqual(self._entezar('nonsense').context['view'], 'today')
|
||||
self.assertEqual(self._ids('nonsense'), set())
|
||||
self.assertIn(a.pk, self._ids('uncoordinated'))
|
||||
|
||||
def test_card_number_matches_filtered_list(self):
|
||||
"""عدد هر کارت باید با تعداد ردیفهای همان فیلتر یکی باشد.
|
||||
@@ -1216,15 +1228,54 @@ class VisitHoldTests(TestCase):
|
||||
# سرِ نوار آمار + توضیح «عقبافتاده»
|
||||
self.assertIn('ez-stats-title', html)
|
||||
self.assertIn('ez-stats-hint', html)
|
||||
# پنج توضیح برای کارتهای بدونقرار/باقرار/… و ششمی برای هماهنگی
|
||||
self.assertEqual(html.count('class="ez-desc"'), 6)
|
||||
for phrase in ('هنوز وقت بازدید تعیین نشده',
|
||||
'وقت بازدیدشان مشخص است',
|
||||
'تعویق دستی از فرم هماهنگی',
|
||||
'اقدام «بازدید» با تاریخ آینده',
|
||||
'پیگیری به آینده موکول شده است'):
|
||||
# چهار کارت: پیگیری امروز + سه کارت وضعیت
|
||||
self.assertEqual(html.count('class="ez-desc"'), 4)
|
||||
for phrase in ('موعد تماسشان امروز است',
|
||||
'با هر وضعیت پیگیری',
|
||||
'هنوز وقت بازدید تعیین نشده',
|
||||
'وقت بعدیشان مشخص است'):
|
||||
self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارتها نیست')
|
||||
|
||||
def test_card_titles_use_business_language(self):
|
||||
"""نام کارتها باید زبان کسبوکار باشد، نه زبان دیتابیس."""
|
||||
html = self._entezar().content.decode()
|
||||
self.assertIn('همهٔ مشتریان عقبافتاده', html)
|
||||
self.assertIn('تعیین تکلیف نشده', html)
|
||||
self.assertIn('تعیین تکلیف شده', html)
|
||||
# نامهای قدیمیِ گیجکننده نباید برگردند.
|
||||
self.assertNotIn('کل عقبافتادهها', html)
|
||||
self.assertNotIn('قرار هماهنگی', html)
|
||||
# «بازدید» بهجای «بازدیدها» — عدد، تعداد مشتری است نه بازدید.
|
||||
self.assertNotIn('کل بازدیدهای عقب افتاده', html)
|
||||
|
||||
def test_extra_cards_moved_into_type_filter(self):
|
||||
"""سه کارت حذفشده باید فقط در دراپداون «نوع قرار» باشند، نه روی کارتها."""
|
||||
html = self._entezar().content.decode()
|
||||
# چهار کارت آمار: «نیازمند پیگیری امروز» + سه کارت وضعیت
|
||||
self.assertEqual(html.count('class="ez-stat '), 4)
|
||||
self.assertIn('نیازمند پیگیری امروز', html)
|
||||
# هر سه فیلتر در دراپداون موجودند
|
||||
self.assertIn('name="view"', html)
|
||||
for value in ('manual', 'scheduled', 'coordination'):
|
||||
self.assertIn(f'value="{value}"', html)
|
||||
|
||||
def test_cards_have_no_breakdown_or_warning(self):
|
||||
"""کارتها باید ساده بمانند: هیچ تفکیک «تعویق/بازدید/پیگیری» رویشان نباشد."""
|
||||
html = self._entezar().content.decode()
|
||||
self.assertNotIn('ez-split', html)
|
||||
self.assertNotIn('ez-alert', html)
|
||||
cards = html.split('class="ez-filters"')[0]
|
||||
# چهار عدد روی کارتها (نه پنج/شش عدد قبلیِ تفکیک)
|
||||
self.assertEqual(cards.count('class="ez-num'), 4)
|
||||
# «تعویق» فقط در تفکیکِ حذفشده بود؛ نباید هیچجای کارتها بماند.
|
||||
self.assertNotIn('تعویق', cards)
|
||||
# هیچ شمارندهای کنار «بازدید» یا «پیگیری» نباید باشد
|
||||
# (الگوی قبلی: «۱۲ بازدید» / «۳۰۶ پیگیری»).
|
||||
for word in ('بازدید', 'پیگیری'):
|
||||
self.assertNotRegex(
|
||||
cards, r'\d+\s*' + word,
|
||||
msg=f'شمارندهٔ کنار «{word}» نباید روی کارتها باشد')
|
||||
|
||||
# ---------- اسکنر پیامک هم قرار دستی را میبیند ----------
|
||||
def test_scanner_skips_customer_with_manual_hold(self):
|
||||
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||||
@@ -1476,7 +1527,11 @@ class EntezarLayoutTests(TestCase):
|
||||
def test_header_cells_match_body_cells(self):
|
||||
thead = self.html.split('<tbody>')[0]
|
||||
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
|
||||
self.assertEqual(thead.count('<th>'), first_row.count('<td'))
|
||||
# سرستونها اکنون لینک مرتبسازی دارند، پس کلاس میگیرند نه <th>ِ ساده.
|
||||
# (``<thead`` هم با «<th» شروع میشود، پس با regex دقیق میشماریم.)
|
||||
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
|
||||
first_row.count('<td'))
|
||||
self.assertEqual(len(re.findall(r'<th[\s>]', thead)), 9)
|
||||
|
||||
def test_no_fixed_min_width_on_columns(self):
|
||||
# min-width روی ستونها باعث اسکرول افقی و بیتناسبی میشد.
|
||||
@@ -1524,33 +1579,40 @@ class EntezarPageControlsTests(TestCase):
|
||||
finish_dt=at(-visit_days_ago))
|
||||
return Customer.objects.get(pk=customer.pk)
|
||||
|
||||
def test_default_view_is_uncoordinated(self):
|
||||
def test_default_view_is_today(self):
|
||||
"""پیشفرض صفحه = «نیازمند پیگیری امروز»."""
|
||||
response = self._get()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['view'], 'uncoordinated')
|
||||
self.assertEqual(response.context['sort'], 'oldest')
|
||||
self.assertEqual(response.context['view'], 'today')
|
||||
self.assertTrue(response.context['is_followup_view'])
|
||||
# ترتیب پیشفرض نمای پیگیری، زودترین موعد است (نه قدیمیترین بازدید).
|
||||
self.assertEqual(response.context['sort'], 'last_visit')
|
||||
self.assertEqual(response.context['sort_dir'], 'asc')
|
||||
|
||||
def test_invalid_view_and_sort_fall_back(self):
|
||||
response = self._get(view='nonsense', sort='bogus')
|
||||
response = self._get(view='nonsense', sort='bogus', delay='bogus')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['view'], 'uncoordinated')
|
||||
self.assertEqual(response.context['sort'], 'oldest')
|
||||
self.assertEqual(response.context['view'], 'today')
|
||||
self.assertEqual(response.context['sort'], 'last_visit')
|
||||
self.assertEqual(response.context['sort_dir'], 'asc')
|
||||
self.assertEqual(response.context['delay'], '')
|
||||
|
||||
def test_counts_partition_total(self):
|
||||
self._overdue_customer('معوق ساده', visit_days_ago=400)
|
||||
counts = self._get().context['counts']
|
||||
counts = self._get(view='uncoordinated').context['counts']
|
||||
self.assertEqual(counts['total'],
|
||||
counts['uncoordinated'] + counts['coordinated'])
|
||||
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
|
||||
|
||||
def test_coordinated_customer_hidden_in_default_view(self):
|
||||
def test_coordinated_customer_hidden_in_uncoordinated_view(self):
|
||||
customer = self._overdue_customer('با قرار', visit_days_ago=400)
|
||||
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
|
||||
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
|
||||
set_visit_hold(customer, at(+10))
|
||||
|
||||
default_ids = [obj.id for obj in self._get().context['customers']]
|
||||
self.assertNotIn(customer.id, default_ids)
|
||||
uncoordinated_ids = [
|
||||
obj.id for obj in self._get(view='uncoordinated').context['customers']]
|
||||
self.assertNotIn(customer.id, uncoordinated_ids)
|
||||
|
||||
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
|
||||
self.assertIn(customer.id, all_ids)
|
||||
@@ -1563,13 +1625,13 @@ class EntezarPageControlsTests(TestCase):
|
||||
coordination = make_visit(customer, action_dt=at(-1),
|
||||
action_type='hamahangi', status='waite')
|
||||
set_visit_hold(customer, at(+10))
|
||||
self.assertNotIn(customer.id,
|
||||
[obj.id for obj in self._get().context['customers']])
|
||||
self.assertNotIn(customer.id, [
|
||||
obj.id for obj in self._get(view='uncoordinated').context['customers']])
|
||||
|
||||
coordination.status = 'cancell'
|
||||
coordination.save(update_fields=['status'])
|
||||
self.assertIn(customer.id,
|
||||
[obj.id for obj in self._get().context['customers']])
|
||||
self.assertIn(customer.id, [
|
||||
obj.id for obj in self._get(view='uncoordinated').context['customers']])
|
||||
|
||||
def test_search_by_name_and_phone(self):
|
||||
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
|
||||
@@ -1578,11 +1640,86 @@ class EntezarPageControlsTests(TestCase):
|
||||
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
|
||||
|
||||
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
|
||||
response = self._get(searchVal=value, searchOn=field)
|
||||
response = self._get(view='uncoordinated', searchVal=value, searchOn=field)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(customer.id,
|
||||
[obj.id for obj in response.context['customers']])
|
||||
|
||||
# ---------- فیلتر «میزان تأخیر» ----------
|
||||
def test_delay_filter_selects_exact_range(self):
|
||||
"""هر بازهٔ تأخیر فقط مشتریان همان بازه را برمیگرداند."""
|
||||
# تأخیر = امروز − (آخرین بازدید + دورهٔ ۹ ماهه).
|
||||
# بازدید ۲۸۰ روز پیش ⇒ تأخیر ~۶ روز (بازهٔ lt30)
|
||||
# بازدید ۷۰۰ روز پیش ⇒ تأخیر ~۴۲۶ روز (بازهٔ gt180)
|
||||
fresh = self._overdue_customer('تازه معوق', visit_days_ago=280)
|
||||
old = self._overdue_customer('کهنه معوق', visit_days_ago=700)
|
||||
|
||||
ids_lt30 = [o.id for o in self._get(view='all', delay='lt30').context['customers']]
|
||||
self.assertIn(fresh.id, ids_lt30)
|
||||
self.assertNotIn(old.id, ids_lt30)
|
||||
|
||||
ids_gt180 = [o.id for o in self._get(view='all', delay='gt180').context['customers']]
|
||||
self.assertNotIn(fresh.id, ids_gt180)
|
||||
self.assertIn(old.id, ids_gt180)
|
||||
|
||||
def test_delay_buckets_sum_to_view_total(self):
|
||||
self._overdue_customer('الف', visit_days_ago=280)
|
||||
self._overdue_customer('ب', visit_days_ago=430)
|
||||
for view in ('all', 'uncoordinated'):
|
||||
counts = self._get(view=view).context['counts']
|
||||
self.assertEqual(sum(counts['buckets'].values()), counts['view_total'],
|
||||
f'جمع بازههای تأخیر با نمای {view} نمیخواند')
|
||||
|
||||
def test_delay_filter_does_not_change_card_counts(self):
|
||||
"""کارتها همیشه کل جامعه را نشان میدهند، نه نتیجهٔ فیلتر را."""
|
||||
self._overdue_customer('الف', visit_days_ago=280)
|
||||
base = self._get().context['counts']
|
||||
filtered = self._get(delay='gt180').context['counts']
|
||||
self.assertEqual(base['total'], filtered['total'])
|
||||
self.assertEqual(base['uncoordinated'], filtered['uncoordinated'])
|
||||
self.assertLessEqual(filtered['selected'], base['selected'])
|
||||
|
||||
# ---------- مرتبسازی ستونها ----------
|
||||
def test_every_column_is_sortable_both_directions(self):
|
||||
self._overdue_customer('الف', visit_days_ago=400)
|
||||
self._overdue_customer('ب', visit_days_ago=500)
|
||||
for column in ('name', 'status', 'manual', 'scheduled', 'coordination',
|
||||
'last_visit', 'overdue', 'phone', 'address'):
|
||||
for direction in ('asc', 'desc'):
|
||||
response = self._get(view='all', sort=column, dir=direction)
|
||||
self.assertEqual(response.status_code, 200,
|
||||
f'ستون {column}/{direction} خطا داد')
|
||||
self.assertEqual(response.context['sort'], column)
|
||||
self.assertEqual(response.context['sort_dir'], direction)
|
||||
|
||||
def test_explicit_direction_overrides_legacy_alias(self):
|
||||
"""``sort=overdue&dir=asc`` باید صعودی بماند، نه به desc قدیمی بیفتد."""
|
||||
self._overdue_customer('الف', visit_days_ago=400)
|
||||
response = self._get(view='all', sort='overdue', dir='asc')
|
||||
self.assertEqual(response.context['sort'], 'overdue')
|
||||
self.assertEqual(response.context['sort_dir'], 'asc')
|
||||
|
||||
def test_name_sort_orders_alphabetically(self):
|
||||
first = self._overdue_customer('آ ا', visit_days_ago=400)
|
||||
second = self._overdue_customer('ی ی', visit_days_ago=400)
|
||||
ids = [o.id for o in self._get(view='all', sort='name', dir='asc').context['customers']]
|
||||
self.assertLess(ids.index(first.id), ids.index(second.id))
|
||||
ids_desc = [o.id for o in self._get(view='all', sort='name', dir='desc').context['customers']]
|
||||
self.assertLess(ids_desc.index(second.id), ids_desc.index(first.id))
|
||||
|
||||
def test_missing_appointment_dates_sort_last(self):
|
||||
"""مشتریِ بدون قرار دستی باید در هر دو جهت، آخرِ فهرست بیاید."""
|
||||
with_hold = self._overdue_customer('با قرار', visit_days_ago=400)
|
||||
make_visit(with_hold, action_dt=at(-1), action_type='hamahangi', status='waite')
|
||||
set_visit_hold(with_hold, at(+10))
|
||||
without = self._overdue_customer('بدون قرار', visit_days_ago=400)
|
||||
|
||||
for direction in ('asc', 'desc'):
|
||||
ids = [o.id for o in self._get(view='all', sort='manual',
|
||||
dir=direction).context['customers']]
|
||||
self.assertLess(ids.index(with_hold.id), ids.index(without.id),
|
||||
f'در جهت {direction} مشتریِ بدون قرار اول آمد')
|
||||
|
||||
def test_sort_overdue_puts_biggest_delay_first(self):
|
||||
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
|
||||
newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
|
||||
@@ -1592,3 +1729,380 @@ class EntezarPageControlsTests(TestCase):
|
||||
|
||||
ids_newest = [obj.id for obj in self._get(view='all', sort='newest').context['customers']]
|
||||
self.assertLess(ids_newest.index(newer.id), ids_newest.index(oldest.id))
|
||||
|
||||
# ---------- سرستونها و حفظ فیلترها ----------
|
||||
def test_sortable_headers_render_with_arrows(self):
|
||||
html = self._get(view='all', sort='name', dir='desc').content.decode()
|
||||
self.assertEqual(html.count('class="ez-th '), 9)
|
||||
# فقط یک سرستون فعال است (کلاس دقیق، نه تکرار در CSS).
|
||||
self.assertEqual(html.count('class="ez-th is-sorted"'), 1)
|
||||
self.assertIn('ez-arrow', html)
|
||||
self.assertIn('▼', html) # جهت نزولیِ فعال
|
||||
self.assertIn('⇅', html) # ستونهای غیرفعال
|
||||
|
||||
def test_header_link_toggles_direction(self):
|
||||
response = self._get(view='all', sort='name', dir='asc')
|
||||
header = {h['key']: h for h in response.context['sort_header']}['name']
|
||||
self.assertTrue(header['active'])
|
||||
self.assertEqual(header['dir'], 'asc')
|
||||
# لینک همان ستون باید جهت را برگرداند.
|
||||
self.assertIn('dir=desc', header['link'])
|
||||
|
||||
def test_filter_links_preserve_other_filters(self):
|
||||
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
|
||||
ctx = response.context
|
||||
for link in ctx['view_links'].values():
|
||||
self.assertIn('delay=gt180', link)
|
||||
self.assertIn('sort=name', link)
|
||||
for link in ctx['delay_links'].values():
|
||||
self.assertIn('sort=name', link)
|
||||
for link in ctx['sort_links'].values():
|
||||
self.assertIn('delay=gt180', link)
|
||||
|
||||
def test_clear_link_resets_everything(self):
|
||||
response = self._get(view='all', delay='gt180', sort='name', dir='asc',
|
||||
searchVal='x', searchOn='name')
|
||||
self.assertEqual(response.context['clear_link'], '?view=today')
|
||||
self.assertTrue(response.context['has_filters'])
|
||||
|
||||
def test_no_filters_means_clean_state(self):
|
||||
self.assertFalse(self._get().context['has_filters'])
|
||||
|
||||
def test_pagination_keeps_filters(self):
|
||||
# یک ردیف بیشتر از اندازهٔ صفحه تا صفحهٔ دوم ساخته شود.
|
||||
for index in range(ENTEZAR_PAGE_SIZE + 1):
|
||||
self._overdue_customer(f'مشتری شماره {index}', visit_days_ago=700)
|
||||
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
|
||||
self.assertTrue(response.context['page_next'])
|
||||
self.assertIn('delay=gt180', response.context['page_next'])
|
||||
self.assertIn('page=2', response.context['page_next'])
|
||||
self.assertIn('sort=name', response.context['page_next'])
|
||||
self.assertEqual(response.context['page_prev'], '')
|
||||
|
||||
|
||||
class TodayFollowupTests(TestCase):
|
||||
"""نمای «نیازمند پیگیری امروز» در صفحهٔ انتظار.
|
||||
|
||||
قواعد قفلشده:
|
||||
* فقط موعدهای **همان روز** (پیشفرض: امروز) نمایش داده میشوند.
|
||||
* مشتریانی که برایشان «بازدید» گذاشته شده **هرگز** در این فهرست نمیآیند.
|
||||
* با تقویم شمسی میتوان روزهای دیگر (گذشته/آینده) را دید.
|
||||
* عدد کارت با تعداد فهرست امروز برابر است و با تغییر تاریخ عوض نمیشود.
|
||||
"""
|
||||
|
||||
URL = '/panel/entezar/'
|
||||
TODAY = timezone.localdate()
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('followupViewer', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# ---------- helpers ----------
|
||||
def _customer(self, name, *, active=True):
|
||||
customer = make_customer(name)
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
installDate=self.TODAY - timedelta(days=400), is_active=active,
|
||||
)
|
||||
customer.refresh_from_db()
|
||||
return customer
|
||||
|
||||
def _coordination(self, customer, day_offset=0):
|
||||
"""اقدام هماهنگی با موعد پیگیری در روز مشخص (نسبت به امروز)."""
|
||||
noon = timezone.make_aware(
|
||||
datetime.datetime.combine(
|
||||
self.TODAY + timedelta(days=day_offset), datetime.time(12, 0)
|
||||
),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
return make_visit(customer, action_dt=noon, action_type='hamahangi',
|
||||
status='finish')
|
||||
|
||||
def _visit(self, customer, day_offset=0):
|
||||
"""اقدام بازدیدِ در انتظار (قرار بازدید)."""
|
||||
noon = timezone.make_aware(
|
||||
datetime.datetime.combine(
|
||||
self.TODAY + timedelta(days=day_offset), datetime.time(10, 0)
|
||||
),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
return make_visit(customer, action_dt=noon, action_type='bazdid',
|
||||
status='waite')
|
||||
|
||||
def _jalali(self, day_offset=0):
|
||||
from persiantools.jdatetime import JalaliDate
|
||||
return JalaliDate(self.TODAY + timedelta(days=day_offset)).strftime('%Y/%m/%d')
|
||||
|
||||
def _ids(self, query=''):
|
||||
response = self.client.get(self.URL + '?view=today' + query)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return {obj.id for obj in response.context['customers']}
|
||||
|
||||
# ---------- هستهٔ رفتار ----------
|
||||
def test_followup_today_appears(self):
|
||||
customer = self._customer('پیگیری امروز')
|
||||
self._coordination(customer, 0)
|
||||
self.assertIn(customer.id, self._ids())
|
||||
|
||||
def test_followup_other_days_not_in_today(self):
|
||||
yesterday = self._customer('دیروز')
|
||||
tomorrow = self._customer('فردا')
|
||||
self._coordination(yesterday, -1)
|
||||
self._coordination(tomorrow, +1)
|
||||
|
||||
ids = self._ids()
|
||||
self.assertNotIn(yesterday.id, ids)
|
||||
self.assertNotIn(tomorrow.id, ids)
|
||||
|
||||
def test_calendar_navigates_to_other_days(self):
|
||||
yesterday = self._customer('دیروز')
|
||||
tomorrow = self._customer('فردا')
|
||||
self._coordination(yesterday, -1)
|
||||
self._coordination(tomorrow, +1)
|
||||
|
||||
past = self._ids('&fdate=' + self._jalali(-1))
|
||||
future = self._ids('&fdate=' + self._jalali(+1))
|
||||
self.assertIn(yesterday.id, past)
|
||||
self.assertNotIn(tomorrow.id, past)
|
||||
self.assertIn(tomorrow.id, future)
|
||||
self.assertNotIn(yesterday.id, future)
|
||||
|
||||
def test_invalid_followup_date_falls_back_to_today(self):
|
||||
customer = self._customer('امروز')
|
||||
self._coordination(customer, 0)
|
||||
self.assertIn(customer.id, self._ids('&fdate=نامعتبر'))
|
||||
|
||||
def test_customer_without_any_followup_never_appears(self):
|
||||
customer = self._customer('بدون موعد')
|
||||
self.assertNotIn(customer.id, self._ids())
|
||||
|
||||
# ---------- قاعدهٔ قطعی: «بازدید» هرگز در این فهرست نمیآید ----------
|
||||
def test_visit_today_excludes_customer(self):
|
||||
"""بازدید برای همان روز ⇒ مشتری نباید در فهرست پیگیری باشد."""
|
||||
customer = self._customer('بازدید امروز')
|
||||
self._coordination(customer, 0)
|
||||
self.assertIn(customer.id, self._ids())
|
||||
|
||||
self._visit(customer, 0)
|
||||
self.assertNotIn(
|
||||
customer.id, self._ids(),
|
||||
msg='مشتریِ دارای بازدیدِ امروز نباید در فهرست پیگیری بیاید',
|
||||
)
|
||||
|
||||
def test_visit_in_future_excludes_customer(self):
|
||||
"""بازدیدی که برای آینده گذاشته شده هم مشتری را از فهرست بیرون میبرد."""
|
||||
customer = self._customer('بازدید آینده')
|
||||
self._coordination(customer, 0)
|
||||
self._visit(customer, +5)
|
||||
self.assertNotIn(customer.id, self._ids())
|
||||
|
||||
def test_visit_in_past_does_not_exclude(self):
|
||||
"""بازدیدِ کهنه (backlog بستهنشده) قرار فعال نیست ⇒ نباید پنهان کند."""
|
||||
customer = self._customer('بازدید کهنه')
|
||||
self._coordination(customer, 0)
|
||||
self._visit(customer, -30)
|
||||
self.assertIn(customer.id, self._ids())
|
||||
|
||||
def test_scheduled_view_still_works(self):
|
||||
"""قاعدهٔ حذف نباید نمای «بازدید برنامهریزیشده» را خالی کند."""
|
||||
customer = self._customer('بازدید برنامه')
|
||||
self._visit(customer, +3)
|
||||
response = self.client.get(self.URL + '?view=scheduled')
|
||||
self.assertIn(customer.id, {o.id for o in response.context['customers']})
|
||||
|
||||
# ---------- قرار دستی روی ستون مشتری ----------
|
||||
def test_manual_hold_today_appears(self):
|
||||
customer = self._customer('قرار دستی امروز')
|
||||
self._coordination(customer, -3)
|
||||
# قرار دستی روی پایان همان روزِ امروز
|
||||
noon_today = timezone.make_aware(
|
||||
datetime.datetime.combine(self.TODAY, datetime.time(12, 0)),
|
||||
timezone.get_current_timezone(),
|
||||
)
|
||||
set_visit_hold(customer, noon_today)
|
||||
self.assertIn(customer.id, self._ids())
|
||||
|
||||
# ---------- شمارندهها و نماها ----------
|
||||
def test_card_count_matches_list(self):
|
||||
first = self._customer('الف')
|
||||
second = self._customer('ب')
|
||||
self._coordination(first, 0)
|
||||
self._coordination(second, 0)
|
||||
response = self.client.get(self.URL)
|
||||
self.assertEqual(response.context['counts']['followup_today'],
|
||||
response.context['counts']['followup_today'])
|
||||
listed = self.client.get(self.URL + '?view=today').context
|
||||
self.assertEqual(listed['counts']['followup_today'],
|
||||
listed['customers'].paginator.count)
|
||||
|
||||
def test_card_count_stays_today_when_browsing_other_day(self):
|
||||
customer = self._customer('فردا')
|
||||
self._coordination(customer, +1)
|
||||
|
||||
today_ctx = self.client.get(self.URL).context
|
||||
past_ctx = self.client.get(
|
||||
self.URL + '?view=today&fdate=' + self._jalali(-1)).context
|
||||
self.assertEqual(today_ctx['counts']['followup_today'],
|
||||
past_ctx['counts']['followup_today'])
|
||||
|
||||
def test_default_view_is_today(self):
|
||||
response = self.client.get(self.URL)
|
||||
self.assertEqual(response.context['view'], 'today')
|
||||
self.assertTrue(response.context['is_followup_view'])
|
||||
|
||||
def test_inactive_and_cancelled_customers_excluded(self):
|
||||
inactive = self._customer('غیرفعال', active=False)
|
||||
cancelled = self._customer('لغو بازدید')
|
||||
self._coordination(inactive, 0)
|
||||
self._coordination(cancelled, 0)
|
||||
Customer.objects.filter(pk=cancelled.pk).update(laghv_bazdid=True)
|
||||
|
||||
ids = self._ids()
|
||||
self.assertNotIn(inactive.id, ids)
|
||||
self.assertNotIn(cancelled.id, ids)
|
||||
|
||||
def test_search_inside_followup_view(self):
|
||||
wanted = self._customer('علی رضایی')
|
||||
other = self._customer('محمد حسینی')
|
||||
self._coordination(wanted, 0)
|
||||
self._coordination(other, 0)
|
||||
|
||||
ids = self._ids('&searchVal=رضایی&searchOn=name')
|
||||
self.assertIn(wanted.id, ids)
|
||||
self.assertNotIn(other.id, ids)
|
||||
|
||||
def test_ordered_by_earliest_followup_time(self):
|
||||
late = self._customer('دیرتر')
|
||||
early = self._customer('زودتر')
|
||||
tz = timezone.get_current_timezone()
|
||||
for customer, hour in ((late, 18), (early, 8)):
|
||||
moment = timezone.make_aware(
|
||||
datetime.datetime.combine(self.TODAY, datetime.time(hour, 0)), tz)
|
||||
make_visit(customer, action_dt=moment, action_type='hamahangi',
|
||||
status='finish')
|
||||
|
||||
response = self.client.get(self.URL + '?view=today')
|
||||
order = [obj.id for obj in response.context['customers']]
|
||||
self.assertLess(order.index(early.id), order.index(late.id))
|
||||
|
||||
def test_date_picker_and_nav_rendered(self):
|
||||
customer = self._customer('نمایش تقویم')
|
||||
self._coordination(customer, 0)
|
||||
html = self.client.get(self.URL + '?view=today').content.decode()
|
||||
self.assertIn('followupDatePicker', html)
|
||||
self.assertIn('pDatepicker', html)
|
||||
self.assertIn('ez-datebar', html)
|
||||
self.assertIn('name="fdate"', html)
|
||||
# پیشفرض صفحه = نمای پیگیری ⇒ نوار تاریخ باید در صفحهٔ بدون پارامتر هم باشد.
|
||||
html_default = self.client.get(self.URL).content.decode()
|
||||
self.assertIn('class="ez-datebar"', html_default)
|
||||
self.assertIn('followupDatePicker', html_default)
|
||||
# در نمای دیگر، نوار تاریخ رندر نشود.
|
||||
html_other = self.client.get(self.URL + '?view=uncoordinated').content.decode()
|
||||
self.assertNotIn('class="ez-datebar"', html_other)
|
||||
self.assertNotIn('followupDatePicker', html_other)
|
||||
|
||||
def test_browsing_other_day_shows_banner(self):
|
||||
customer = self._customer('روز دیگر')
|
||||
self._coordination(customer, -1)
|
||||
html = self.client.get(
|
||||
self.URL + '?view=today&fdate=' + self._jalali(-1)).content.decode()
|
||||
self.assertIn('بازگشت به امروز', html)
|
||||
|
||||
# ---------- ستون «تأخیر» فقط در نمای پیگیری مخفی ----------
|
||||
def test_overdue_column_hidden_only_in_followup_view(self):
|
||||
customer = self._customer('ستون تأخیر')
|
||||
self._coordination(customer, 0)
|
||||
|
||||
followup_html = self.client.get(self.URL + '?view=today').content.decode()
|
||||
self.assertNotIn('data-label="تأخیر"', followup_html)
|
||||
self.assertNotIn('>تأخیر<', followup_html)
|
||||
|
||||
other_html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
|
||||
self.assertIn('data-label="تأخیر"', other_html)
|
||||
|
||||
def test_column_widths_sum_to_hundred_in_both_modes(self):
|
||||
customer = self._customer('ردیف نمونه')
|
||||
self._coordination(customer, 0)
|
||||
for query in ('?view=today', '?view=all'):
|
||||
html = self.client.get(self.URL + query).content.decode()
|
||||
widths = [float(w) for w in re.findall(
|
||||
r'<col style="width: (\d+(?:\.\d+)?)%">', html)]
|
||||
self.assertAlmostEqual(sum(widths), 100.0, places=1,
|
||||
msg=f'جمع عرض ستونها در {query} برابر ۱۰۰ نیست')
|
||||
# تعداد <th> با تعداد <td> هر ردیف باید یکی باشد.
|
||||
thead = html.split('<tbody>')[0]
|
||||
first_row = html.split('<tbody>')[1].split('</tr>')[0]
|
||||
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
|
||||
first_row.count('<td'))
|
||||
|
||||
# ---------- صفحهبندی ۱۰۰تایی ----------
|
||||
def test_page_size_is_one_hundred(self):
|
||||
for index in range(105):
|
||||
self._customer(f'مشتری شماره {index}')
|
||||
page = self.client.get(self.URL + '?view=all').context['customers']
|
||||
self.assertEqual(len(page.object_list), 100)
|
||||
self.assertEqual(page.paginator.num_pages, 2)
|
||||
|
||||
# ---------- هشدار پیگیریهای عقبافتاده ----------
|
||||
def test_overdue_warning_appears_for_past_due(self):
|
||||
late = self._customer('موعد گذشته')
|
||||
self._coordination(late, -3)
|
||||
|
||||
ctx = self.client.get(self.URL).context
|
||||
self.assertEqual(ctx['counts']['followup_overdue'], 1)
|
||||
html = self.client.get(self.URL).content.decode()
|
||||
self.assertIn('class="ez-overdue-alert"', html)
|
||||
self.assertIn('مورد نیازمند پیگیری از روزهای قبل باقی مانده', html)
|
||||
|
||||
def test_overdue_warning_absent_when_all_on_time(self):
|
||||
today_only = self._customer('فقط امروز')
|
||||
self._coordination(today_only, 0)
|
||||
future = self._customer('فردا')
|
||||
self._coordination(future, +2)
|
||||
|
||||
ctx = self.client.get(self.URL).context
|
||||
self.assertEqual(ctx['counts']['followup_overdue'], 0)
|
||||
self.assertNotIn('class="ez-overdue-alert"',
|
||||
self.client.get(self.URL).content.decode())
|
||||
|
||||
def test_overdue_warning_matches_missed_list(self):
|
||||
"""عدد هشدار باید دقیقاً برابر تعداد ردیفهای فهرست «عقبافتادهها» باشد."""
|
||||
for offset in (-1, -5, -30, 0):
|
||||
customer = self._customer(f'موعد {offset}')
|
||||
self._coordination(customer, offset)
|
||||
|
||||
ctx = self.client.get(self.URL).context
|
||||
missed = self.client.get(self.URL + '?view=today&missed=1').context
|
||||
self.assertEqual(missed['customers'].paginator.count,
|
||||
ctx['counts']['followup_overdue'])
|
||||
self.assertEqual(missed['counts']['followup_overdue'],
|
||||
ctx['counts']['followup_overdue'])
|
||||
|
||||
def test_newer_coordination_clears_overdue(self):
|
||||
customer = self._customer('تمدید شده')
|
||||
self._coordination(customer, -10)
|
||||
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 1)
|
||||
|
||||
# کارشناس دوباره تماس گرفته و موعد جدید گذاشته ⇒ دیگر عقبافتاده نیست.
|
||||
self._coordination(customer, +5)
|
||||
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
|
||||
|
||||
def test_scheduled_visit_clears_overdue(self):
|
||||
customer = self._customer('بازدید گرفته')
|
||||
self._coordination(customer, -10)
|
||||
self._visit(customer, +1)
|
||||
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
|
||||
|
||||
def test_missed_mode_renders_chip_and_back_link(self):
|
||||
customer = self._customer('عقبافتاده')
|
||||
self._coordination(customer, -4)
|
||||
html = self.client.get(self.URL + '?view=today&missed=1').content.decode()
|
||||
self.assertIn('class="ez-overdue-mode"', html)
|
||||
self.assertNotIn('<a class="ez-overdue-alert"', html)
|
||||
self.assertIn('بازگشت به امروز', html)
|
||||
|
||||
def test_overdue_warning_only_in_followup_view(self):
|
||||
customer = self._customer('عقبافتاده دیگر')
|
||||
self._coordination(customer, -4)
|
||||
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
|
||||
self.assertNotIn('class="ez-overdue-alert"', html)
|
||||
Reference in new issue
Block a user