improve bazdid

This commit is contained in:
javad committed 2026-10-21 10:50:42 +03:30
1 parent 2aee85593f
commit 534a84f977
12 files changed
+2305 -339

No files matched your search

+2
View File
@@ -305,6 +305,8 @@ class ApplyTests(CommandTestMixin, TestCase):
"id", "name_family", "address", "notes", "phone1", "phone2",
"phone3", "idCode", "installDate", "create_date", "last_bazdid_dt",
"laghv_bazdid", "is_active", "sms_primary_phones",
# «قرار بعدی» (تعویق بازدید از فرم هماهنگی) + زمان ثبت برای حسابرسی.
"visit_hold_until", "visit_hold_set_at",
})
self.assertNotIn("gender", field_names)
+563 -41
View File
@@ -9,20 +9,24 @@
* بازدید انجام‌شده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد می‌شود.
"""
import datetime
import re
import io
import os
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ValidationError
from django.contrib.auth.models import Permission
from django.core.management import CommandError, call_command
from django.db import connection
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.models import Action, Customer, PhoneNumber
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
last_visit_map, refresh_last_bazdid,
last_visit_map, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
validate_action_payload,
visit_cycle_index, visit_due_date)
@@ -761,56 +765,574 @@ class NextAppointmentEquivalenceTests(TestCase):
self.assertNotIn(customer_c.pk, result)
class VisitHoldTests(TestCase):
"""«قرار بعدی» (تعویق از فرم هماهنگی): هستهٔ رفع باگ + ریسک‌های شناسایی‌شده."""
def setUp(self):
from tests.factories import make_user
self.user = make_user('holdExpert', superuser=True)
self.client.force_login(self.user)
# ---------- helpers ----------
def _customer(self, name):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
customer.refresh_from_db()
return customer
def _entezar(self, view=''):
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=''):
return {c.id for c in self._entezar(view).context['customers']}
def _post_action(self, customer, **payload):
base = {'desc': 'تست', 'userID': self.user.id}
base.update(payload)
for key in [k for k, v in list(base.items()) if v is None]:
base.pop(key)
return self.client.post(
reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.pk}), base,
)
def _ms(self, moment):
return str(int(moment.timestamp() * 1000))
# ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
def test_hold_until_today_hides_customer_until_end_of_day(self):
customer = self._customer('قرار امروز')
self._post_action(
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
is_done='on', visitHoldUntil=self._ms(timezone.now()),
)
customer.refresh_from_db()
self.assertIsNotNone(customer.visit_hold_until)
local = customer.visit_hold_until.astimezone(timezone.get_current_timezone())
self.assertEqual((local.hour, local.minute), (23, 59))
self.assertNotIn(customer.pk, self._ids())
# ---------- ریسک ۲: finish بدون تاریخ قرار ⇒ پیش‌فرض ۱۴ روز ----------
def test_finished_coordination_without_hold_defaults_to_two_weeks(self):
customer = self._customer('هماهنگی بدون تاریخ قرار')
self._post_action(
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
is_done='on',
)
customer.refresh_from_db()
self.assertIsNotNone(customer.visit_hold_until)
days = (customer.visit_hold_until.date() - timezone.localdate()).days
self.assertEqual(days, 14)
self.assertNotIn(customer.pk, self._ids())
# ---------- ریسک ۳: حذف/لغو هماهنگی ⇒ قرار بی‌اثر ----------
def test_hold_becomes_inert_when_coordination_is_deleted(self):
customer = self._customer('حذف هماهنگی')
self._post_action(
customer, actionType='hamahangi',
dateTimeAnjam=self._ms(timezone.now() + timedelta(days=10)),
is_done='on',
visitHoldUntil=self._ms(timezone.now() + timedelta(days=10)),
)
self.assertNotIn(customer.pk, self._ids())
Action.objects.filter(customer=customer).delete()
customer.refresh_from_db()
self.assertIsNotNone(customer.visit_hold_until) # ستون باقی است…
self.assertIn(customer.pk, self._ids()) # …ولی بی‌اثر شده است
def test_hold_becomes_inert_when_coordination_is_cancelled(self):
customer = self._customer('لغو هماهنگی')
action = Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=10),
finishDateTime=timezone.now(),
)
set_visit_hold(customer, timezone.now() + timedelta(days=10))
self.assertNotIn(customer.pk, self._ids())
Action.objects.filter(pk=action.pk).update(status='cancell')
self.assertIn(customer.pk, self._ids())
# ---------- ریسک ۴: قاعدهٔ «فقط رو به جلو» ----------
def test_second_coordination_does_not_pull_hold_backwards(self):
customer = self._customer('قرار قبلی دورتر')
set_visit_hold(customer, timezone.now() + timedelta(days=30))
written = set_visit_hold(customer, timezone.now() + timedelta(days=5))
self.assertFalse(written)
customer.refresh_from_db()
self.assertEqual(
customer.visit_hold_until.date(),
(timezone.now() + timedelta(days=30)).date(),
)
def test_second_coordination_can_extend_hold(self):
customer = self._customer('تمدید قرار')
set_visit_hold(customer, timezone.now() + timedelta(days=5))
written = set_visit_hold(customer, timezone.now() + timedelta(days=20))
self.assertTrue(written)
customer.refresh_from_db()
self.assertEqual(
customer.visit_hold_until.date(),
(timezone.now() + timedelta(days=20)).date(),
)
# ---------- سقف ۶ ماه و تاریخ گذشته (اعتبارسنجی سرور) ----------
def test_hold_beyond_six_months_is_rejected(self):
customer = self._customer('تعویق بلند')
response = self._post_action(
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=200)),
)
self.assertEqual(response.status_code, 302)
self.assertFalse(Action.objects.filter(customer=customer).exists())
customer.refresh_from_db()
self.assertIsNone(customer.visit_hold_until)
def test_hold_in_the_past_is_rejected(self):
with self.assertRaises(ValidationError):
normalize_hold_until(timezone.now() - timedelta(days=3))
def test_hold_none_returns_none(self):
self.assertIsNone(normalize_hold_until(None))
# ---------- بازدید واقعی ⇒ مشتری از فهرست خارج می‌شود ----------
def test_real_visit_clears_hold_and_leaves_list(self):
customer = self._customer('بازدید واقعی')
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=10),
finishDateTime=timezone.now())
set_visit_hold(customer, timezone.now() + timedelta(days=10))
self.assertNotIn(customer.pk, self._ids())
self._post_action(
customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now()),
is_done='on', FinishDateTime=self._ms(timezone.now()), result='انجام شد',
)
customer.refresh_from_db()
self.assertIsNotNone(customer.last_bazdid_dt)
self.assertNotIn(customer.pk, self._ids('all'))
# ---------- هماهنگیِ finish با تاریخ آینده حتی بدون ستون، قرار می‌سازد ----------
def test_coordination_without_hold_still_hides_via_action(self):
customer = self._customer('هماهنگی بدون ستون')
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=6),
finishDateTime=timezone.now(),
)
self.assertNotIn(customer.pk, self._ids())
# ---------- تفکیک چهارگروهی و فیلترها ----------
def test_groups_and_filters(self):
a = self._customer('الف بدون قرار')
b = self._customer('ب قرار دستی')
c = self._customer('ج بازدید برنامه')
d = self._customer('د هر دو')
# b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد).
Action.objects.create(customer=b, actionType='hamahangi', status='finish',
actionDateTime=timezone.now(), finishDateTime=timezone.now())
set_visit_hold(b, timezone.now() + timedelta(days=20))
Action.objects.create(customer=c, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=7))
# d: هم قرار دستی و هم بازدید برنامه‌ریزی‌شده.
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
actionDateTime=timezone.now(), finishDateTime=timezone.now())
set_visit_hold(d, timezone.now() + timedelta(days=25))
Action.objects.create(customer=d, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=9))
counts = self._entezar().context['counts']
self.assertEqual(counts['uncoordinated'], 1) # a
self.assertEqual(counts['manual'], 1) # b
self.assertEqual(counts['scheduled'], 1) # c
self.assertEqual(counts['both'], 1) # d
self.assertEqual(counts['total'], 4)
self.assertEqual(counts['coordinated'], 3)
self.assertEqual(self._ids(), {a.pk})
self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk})
self.assertEqual(self._ids('manual'), {b.pk, d.pk})
self.assertEqual(self._ids('scheduled'), {c.pk, d.pk})
self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk})
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')
# ---------- اسکنر پیامک هم قرار دستی را می‌بیند ----------
def test_scanner_skips_customer_with_manual_hold(self):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
from admin_panel.sms.scanner import scan_visit
customer = self._customer('قرار + اسکنر')
PhoneNumber.objects.create(customer=customer, phone_number='09120000555')
config = SMSProviderConfig.load()
config.allow_backlog = True
config.backlog_max = 50
config.scan_since = timezone.now() - timedelta(days=2000)
config.save()
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=15),
finishDateTime=timezone.now())
set_visit_hold(customer, timezone.now() + timedelta(days=15))
scan_visit(timezone.localdate())
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
# ---------- اجتماع منابع ----------
def test_next_appointment_is_union_of_sources(self):
from admin_panel.visit_policy import next_appointment_map
customer = self._customer('اجتماع منابع')
now = timezone.now()
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
actionDateTime=now + timedelta(days=30),
finishDateTime=now)
set_visit_hold(customer, now + timedelta(days=20))
merged = next_appointment_map(now=now)
self.assertIn(customer.pk, merged)
# قرار دستی «پایان روز» است، پس مقایسه در سطح تاریخ انجام می‌شود.
self.assertLessEqual(merged[customer.pk].date(), (now + timedelta(days=20)).date())
class VisitDedupCleanupTests(TestCase):
"""پاک‌سازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
def _keys(self):
from admin_panel.models import SMSDedup
return set(SMSDedup.objects.values_list('key', flat=True))
class ActionModalTemplateTests(TestCase):
"""قرارداد فرم «ایجاد وظیفه» (`customerActionInc.html`).
def test_removes_only_absurd_keys_by_default(self):
from admin_panel.models import SMSDedup
این تست‌ها تضمین می‌کنند بازطراحی UI، نام فیلدها/شناسه‌هایی را که ویو
انتظار دارد تغییر ندهد (چون ویو با ``request.POST.get(name)`` می‌خواند) و
مودال با همان تقویم شمسی موجود کار کند.
"""
SMSDedup.objects.bulk_create([
SMSDedup(key='visit:10:24663'), # عدد بی‌معنا (باگ تقسیم قدیمی)
SMSDedup(key='visit:11:1'), # قدیمی ولی منطقی
SMSDedup(key='visit:12:2026-03'), # قالب جدید
SMSDedup(key='welcome:5'), # نوع دیگر
])
call_command('cleanup_visit_dedup', stdout=io.StringIO())
self.assertEqual(
self._keys(),
{'visit:11:1', 'visit:12:2026-03', 'welcome:5'},
def setUp(self):
self.user = make_user('modal_viewer')
self.user.user_permissions.add(*Permission.objects.filter(
content_type__app_label='admin_panel'))
self.client.force_login(self.user)
self.customer = make_customer('مشتری مودال')
self.html = self.client.get(
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
).content.decode()
def test_expected_form_fields_exist(self):
# این‌ها همان کلیدهایی هستند که ویو از request.POST می‌خواند.
for name in ('actionType', 'dateTimeAnjam', 'visitHoldUntil', 'desc',
'userID', 'is_done', 'FinishDateTime', 'result'):
self.assertIn(f'name="{name}"', self.html, f'فیلد {name} گم شده است')
def test_javascript_hooks_exist(self):
# JS داخل قالب و ویوهای دیگر به این شناسه‌ها و کلاس‌ها وابسته‌اند.
for element_id in ('actionType', 'userID', 'checkIsDone', 'sabtBtn05',
'ActionBox', 'holdHelpToggle', 'holdHelpPanel'):
self.assertIn(f'id="{element_id}"', self.html, f'شناسه {element_id} گم شده است')
for cls in ('closeActionBox', 'showWhenDone', 'showWhenCoordination'):
self.assertIn(cls, self.html, f'کلاس {cls} گم شده است')
def test_persian_datepickers_are_used(self):
for cls in ('dateTimePickerAnjam', 'dateTimePickerFinish', 'dateTimePickerHoldUntil'):
self.assertIn(cls, self.html, f'تقویم {cls} گم شده است')
def test_help_panel_is_collapsed_by_default(self):
# پنل راهنما باید بسته باشد و با aria-expanded مدیریت شود.
self.assertIn('aria-expanded="false"', self.html)
self.assertIn('ax-help-panel is-hidden', self.html)
self.assertIn('aria-controls="holdHelpPanel"', self.html)
def test_help_is_scoped_to_coordination(self):
# راهنما و فیلد تاریخ پیگیری فقط داخل بلوک «هماهنگی» هستند.
for marker in ('ax-help-wrap showWhenCoordination is-hidden',
'ax-field showWhenCoordination is-hidden'):
self.assertIn(marker, self.html, f'دامنهٔ نمایش {marker} اشتباه است')
def test_form_still_posts_to_new_action(self):
self.assertIn(
reverse('admin_panel:customerNewAction', kwargs={'c_id': self.customer.pk}),
self.html,
)
def test_all_legacy_flag_removes_numeric_keys_too(self):
from admin_panel.models import SMSDedup
def test_ui_classes_are_namespaced(self):
# پیشوند ax- جلوی تداخل استایل با سایر صفحات را می‌گیرد.
self.assertIn('ax-card', self.html)
self.assertIn('ax-section', self.html)
SMSDedup.objects.bulk_create([
SMSDedup(key='visit:11:1'),
SMSDedup(key='visit:12:2026-03'),
])
call_command('cleanup_visit_dedup', '--all-legacy', stdout=io.StringIO())
self.assertEqual(self._keys(), {'visit:12:2026-03'})
def test_dry_run_deletes_nothing(self):
from admin_panel.models import SMSDedup
class CoordinationDoneConfirmTests(TestCase):
"""پیش‌فرض «انجام شده» برای هماهنگی + هشدار و تأیید پیش از ثبت.
SMSDedup.objects.create(key='visit:10:24663')
call_command('cleanup_visit_dedup', '--dry-run', stdout=io.StringIO())
self.assertEqual(self._keys(), {'visit:10:24663'})
قواعد قفل‌شده:
* فقط «هماهنگی» تیکِ خودکار می‌گیرد؛ سایر انواع اقدام دست‌نخورده‌اند.
* پیش از ثبتِ هماهنگیِ انجام‌شده، تأیید گرفته می‌شود.
* در حالت ویرایش و برای انواع دیگر، هیچ تأییدی در مسیر نیست.
"""
def test_customer_and_action_rows_untouched(self):
from admin_panel.models import SMSDedup
def setUp(self):
self.user = make_user('confirm_viewer')
self.user.user_permissions.add(*Permission.objects.filter(
content_type__app_label='admin_panel'))
self.client.force_login(self.user)
self.customer = make_customer('مشتری تأیید هماهنگی')
self.html = self.client.get(
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
).content.decode()
customer = make_customer('مشتری پاک‌سازی کلید')
action = make_visit(customer, action_dt=at(-30), finish_dt=at(-30))
before_action = action_snapshot(action)
before_customer = Customer.objects.values().get(pk=customer.pk)
SMSDedup.objects.create(key='visit:1:24663')
def test_warning_panel_starts_hidden(self):
self.assertIn('id="holdDoneWarning"', self.html)
block = self.html.split('id="holdDoneWarning"')[0].rsplit('<div', 1)[-1]
self.assertIn('is-hidden', block, 'پنل هشدار نباید از ابتدا باز باشد')
call_command('cleanup_visit_dedup', stdout=io.StringIO())
def test_warning_text_is_present(self):
self.assertIn('آیا مطمئن هستی که هماهنگی را انجام دادی؟', self.html)
self.assertIn('اگر هنوز هماهنگی نکردی، این تیک را بردار.', self.html)
self.assertEqual(action_snapshot(action), before_action)
self.assertEqual(Customer.objects.values().get(pk=customer.pk), before_customer)
self.assertEqual(self._keys(), set())
def test_confirm_buttons_exist(self):
self.assertIn('id="holdConfirmYes"', self.html)
self.assertIn('id="holdConfirmNo"', self.html)
def test_autocheck_only_for_coordination(self):
# تیک خودکار باید داخل شاخهٔ hamahangi باشد و برای بقیه برداشته شود.
block = self.html.split("$(\"select#actionType\").on('change'", 1)[1]
block = block.split("});", 1)[0]
self.assertIn("type === 'hamahangi'", block)
self.assertIn('holdAutoChecked = true', block)
self.assertIn('holdAutoChecked = false', block)
def test_confirmation_is_gated_before_submit(self):
block = self.html.split('$("#ActionBox").on(\'submit\'', 1)[1]
block = block.split('});', 1)[0]
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
self.assertIn('isEditMode || holdConfirmed', block)
self.assertIn("!== 'hamahangi'", block)
self.assertIn('prop("checked")', block)
self.assertIn('event.preventDefault()', block)
def test_cancelling_confirmation_unchecks(self):
block = self.html.split('$("#holdConfirmNo").click', 1)[1]
block = block.split('});', 1)[0]
self.assertIn('prop("checked", false)', block)
def test_edit_mode_skips_confirmation(self):
# با کلیک روی ردیف موجود، isEditMode روشن می‌شود ⇒ مسیر تأیید رد می‌شود.
self.assertIn('isEditMode = true', self.html)
self.assertIn('isEditMode = false', self.html)
def test_other_action_types_keep_old_done_toggle(self):
# تیک «انجام شده» همچنان فیلدهای اتمام/نتیجه را نشان می‌دهد.
self.assertIn('function syncDoneFields', self.html)
self.assertIn('showWhenDone', self.html)
def test_every_picker_selector_matches_markup(self):
"""هر کلاسی که در JS برای ساخت تقویم استفاده می‌شود باید در قالب باشد.
این تست جلوی باگ «تقویم باز نمی‌شود» را می‌گیرد: اگر نام کلاس در قالب
با سلکتور جاوااسکریپت یکی نباشد، ``pDatepicker`` روی مجموعهٔ خالی
اجرا می‌شود، ``undefined`` برمی‌گرداند و ``setDate`` با TypeError
می‌شکند — که باعث از کار افتادن بقیهٔ منطق مودال هم می‌شد.
"""
selectors = re.findall(r'\$\("\.([\w-]+)"\)\.pDatepicker\(', self.html)
self.assertTrue(selectors, 'هیچ پیکری در قالب پیدا نشد')
for selector in selectors:
self.assertRegex(
self.html,
rf'class="[^"]*\b{re.escape(selector)}\b',
f'تقویم .{selector} در JS هست ولی در قالب هیچ المانی با این کلاس نیست',
)
def test_every_alt_field_class_exists_in_markup(self):
"""کلاس ``altField`` هر پیکر باید در قالب وجود داشته باشد."""
alt_fields = re.findall(r"altField:\s*'\.([\w-]+)'", self.html)
self.assertTrue(alt_fields, 'هیچ altField ای پیدا نشد')
for selector in alt_fields:
self.assertRegex(
self.html,
rf'class="[^"]*\b{re.escape(selector)}\b',
f'altField .{selector} در قالب وجود ندارد',
)
def test_hold_picker_calls_are_guarded(self):
"""فراخوانی setDate پیکر «تاریخ پیگیری» باید فقط از مسیر محافظ‌شده باشد.
چون ``pDatepicker`` در صورت عدم تطبیق سلکتور ``undefined`` می‌دهد،
فراخوانی مستقیم ``.setDate()`` کل هندلر را می‌شکند. پس تنها فراخوانی
مجاز، همان یکی داخل ``resetHoldPicker`` است و بقیهٔ نقاط باید از آن
استفاده کنند.
"""
self.assertIn('function resetHoldPicker', self.html)
calls = re.findall(r'dateTimePickerHoldUntil\.setDate\(\)', self.html)
self.assertEqual(len(calls), 1,
'همهٔ ریست‌ها باید از resetHoldPicker عبور کنند')
# آن یک فراخوانی باید داخل تابع محافظ باشد (بعد از بررسی typeof).
func = self.html.split('function resetHoldPicker', 1)[1].split('\n }', 1)[0]
self.assertIn('typeof dateTimePickerHoldUntil.setDate === \'function\'', func)
self.assertIn('dateTimePickerHoldUntil.setDate()', func)
# نقاط استفاده باید resetHoldPicker را صدا بزنند.
self.assertGreaterEqual(self.html.count('resetHoldPicker()'), 3)
class EntezarLayoutTests(TestCase):
"""ثابت‌های چیدمان صفحهٔ انتظار (جلوگیری از رگرسیون UI).
این تست‌ها چیزهایی را قفل می‌کنند که با یک ویرایش ساده می‌شکنند و در
تست‌های منطقی دیده نمی‌شوند: تناسب ستون‌ها، حذف ستون «ردیف»، و هم‌ترازی
مرز جدول/کارت.
"""
URL = '/panel/entezar/'
def setUp(self):
self.user = make_user('layout_viewer')
self.user.user_permissions.add(*Permission.objects.filter(
content_type__app_label='admin_panel'))
self.client.force_login(self.user)
customer = make_customer('مشتری چیدمان')
Customer.objects.filter(pk=customer.pk).update(
installDate=(timezone.now() - timedelta(days=900)).date(),
phone1='09121234567', idCode='C-1',
)
make_visit(customer, action_dt=at(-400), finish_dt=at(-400))
self.html = self.client.get(self.URL, {'view': 'all'}).content.decode()
def test_column_widths_sum_to_one_hundred(self):
widths = [float(w) for w in re.findall(
r'<col style="width: (\d+(?:\.\d+)?)%">', self.html)]
self.assertEqual(len(widths), 8, 'تعداد ستون‌ها باید ۸ باشد')
self.assertAlmostEqual(sum(widths), 100.0, places=1)
def test_row_number_is_not_a_column(self):
# شمارهٔ ردیف به چیپ کنار نام تبدیل شده، نه ستونِ جداگانه.
self.assertNotIn('<th>ردیف</th>', self.html)
self.assertIn('ez-idx', self.html)
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'))
def test_no_fixed_min_width_on_columns(self):
# min-width روی ستون‌ها باعث اسکرول افقی و بی‌تناسبی می‌شد.
self.assertEqual(re.findall(r'<th[^>]*min-width', self.html), [])
def test_card_and_table_breakpoints_agree(self):
# اگر مرز کارت (max-width) و جدول (min-width) یکی نباشند، محدوده‌ای
# از عرض صفحه هر دو حالت را هم‌زمان می‌گیرد و چیدمان می‌شکند.
cards = re.findall(r'@media \(max-width: (\d+)px\)', self.html)
tables = [w for w in re.findall(r'@media \(min-width: (\d+)px\)', self.html)
if w != '1024'] # 1024 مخصوص نوار آمار است
self.assertTrue(cards, 'بلوک کارت موبایل پیدا نشد')
self.assertIn(str(int(cards[0]) + 1), tables)
def test_phone_renders_left_to_right(self):
self.assertIn('ez-phone', self.html)
# شمارهٔ تلفن باید در یک تکه و بدون شکست نمایش داده شود.
self.assertIn('ez-nowrap', self.html)
class EntezarPageControlsTests(TestCase):
"""کنترل‌های صفحهٔ *انتظار*: فیلتر نما، مرتب‌سازی، جست‌وجو و کارت‌های آمار.
این تست‌ها رفتار کاربری را قفل می‌کنند: هر عدد کارت باید با تعداد همان
نما بخواند و جست‌وجو/مرتب‌سازی نباید ردیف‌ها را از دست بدهد.
"""
URL = '/panel/entezar/'
def setUp(self):
self.user = make_user('entezar_viewer')
self.user.user_permissions.add(*Permission.objects.filter(
content_type__app_label='admin_panel'))
self.client.force_login(self.user)
def _get(self, **params):
return self.client.get(self.URL, params)
def _overdue_customer(self, name, install_days_ago=900, visit_days_ago=None):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=(timezone.now() - timedelta(days=install_days_ago)).date())
if visit_days_ago is not None:
make_visit(customer, action_dt=at(-visit_days_ago),
finish_dt=at(-visit_days_ago))
return Customer.objects.get(pk=customer.pk)
def test_default_view_is_uncoordinated(self):
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'uncoordinated')
self.assertEqual(response.context['sort'], 'oldest')
def test_invalid_view_and_sort_fall_back(self):
response = self._get(view='nonsense', sort='bogus')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'uncoordinated')
self.assertEqual(response.context['sort'], 'oldest')
def test_counts_partition_total(self):
self._overdue_customer('معوق ساده', visit_days_ago=400)
counts = self._get().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):
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)
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
self.assertIn(customer.id, all_ids)
manual_ids = [obj.id for obj in self._get(view='manual').context['customers']]
self.assertIn(customer.id, manual_ids)
def test_cancelled_coordination_returns_customer_to_list(self):
customer = self._overdue_customer('هماهنگی لغو شده', visit_days_ago=400)
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']])
coordination.status = 'cancell'
coordination.save(update_fields=['status'])
self.assertIn(customer.id,
[obj.id for obj in self._get().context['customers']])
def test_search_by_name_and_phone(self):
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
PhoneNumber.objects.create(customer=customer, title='تلفن',
phone_number='09121234567')
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
response = self._get(searchVal=value, searchOn=field)
self.assertEqual(response.status_code, 200)
self.assertIn(customer.id,
[obj.id for obj in response.context['customers']])
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)
ids = [obj.id for obj in self._get(view='all', sort='overdue').context['customers']]
self.assertLess(ids.index(oldest.id), ids.index(newer.id))
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))