improve bazdid
This commit is contained in:
1 parent
2aee85593f
commit
534a84f977
12 files changed
+2305
-339
No files matched your search
@@ -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
@@ -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))
|
||||
Reference in new issue
Block a user