improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 15:13:35 +03:30
1 parent f86d060e4d
commit 8fd881e0ea
10 files changed
+1752 -320

No files matched your search

+113
View File
@@ -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
View File
@@ -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)