Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dev/sticky_notes_extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@

הדגימה: כשל אינו "אין", ו-401 אינו כשל
---------------------------------------------
בלוק הדגימה ב-``base.html`` (``initStickyRemindersIndicator``) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו.
בלוק הדגימה ב-``base.html`` (``initStickyRemindersIndicator``) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. וכשיש בועה על המסך השרת עונה לכל היותר חמש דקות — ומוקדם יותר כשתזכורת נוספת מבשילה — כדי שניקוי ממכשיר אחר ומונה שמשתנה ייראו תוך כדי ולא אחרי חצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו.

**כל כשל הוא "לא ידוע", ולכולם מונה אחד ו-backoff אחד.** תשובת שגיאה, שגיאת רשת, JSON פגום ו-timeout נכנסים כולם לאותה פונקציה: המונה עולה, ההמתנה מכפילה את עצמה מדקה עד התקרה, והיא נכתבת לאותו ``__stickyRemindersBackoffUntil`` שמשרת את 429 — כך ש-``visibilitychange`` מכבד אותה בלי לדעת עליה. הצלחה מאפסת את המונה; בלי האיפוס, מצב הדיכוי מהתקלה הקודמת היה מרעיל את החלון הבא. הגרסה הקודמת חזרה כל דקה לנצח על כל כשל, ונרדמה חצי שעה על שגיאת רשת — שני הקצוות הלא נכונים, ודווקא כשהשרת נופל. והבועה שכבר הוצגה נשארת: היא המצב האחרון הידוע, וכשל אינו ראיה שהתזכורות נעלמו; רק תשובה שאומרת "אין" ו-401 מסירים אותה. ‏429 אינו כשל ואינו "אין": השרת נוקב בעצמו בהמתנה (``Retry-After``, ולפחות רבע שעה), היא נכתבת לאותו חלון, המונה אינו זז, והבועה נשארת. וכל כשל נרשם בקונסול הדפדפן — מספר הכשל, ההמתנה הבאה והסיבה — כי כשל שקט נראה בדיוק כמו "אין תזכורות".

Expand Down
4 changes: 2 additions & 2 deletions docs/webapp/api-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ Endpoints
- ``image/svg+xml``
* - ``/api/sticky-notes/reminders/summary``
- GET
- סיכום מינימלי לבועה הקבועה (האם יש פתקים ממתינים + מונה)
- סיכום מינימלי לבועה הקבועה (האם יש פתקים ממתינים, מונה, ובעוד כמה שניות לשאול שוב)
- ✅
- -
- ``{"ok": true, "has_due": true, "count_due": 3, "next": {"note_id": "...", "file_id": "...", "remind_at": "..."}}``
- ``{"ok": true, "has_due": true, "count_due": 3, "next_in_seconds": 300}``
* - ``/api/sticky-notes/reminders/list``
- GET
- רשימת פתקים ממתינים עם תצוגה מקדימה (6 מילים) וקישור לעוגן
Expand Down
114 changes: 109 additions & 5 deletions tests/test_sticky_note_reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,31 @@ def _matches(doc, query):
return True


def _project(doc, projection):
"""מחיל היטלה כמו מונגו, במקום לזרוק אותה.

הכללה (``{f: 1}``) שומרת את השדות שנקבו ואת ``_id`` אלא אם ``_id: 0``;
החרגה (``{f: 0}``) מסירה את השדות שנקבו. בלי היטלה חוזר המסמך עצמו.
הסטאב הישן קיבל ``projection`` וזרק אותו, ולכן טסט ראה מסמך מלא היכן
שהייצור רואה שדה אחד — שינוי שמפיל שדה מההיטלה עבר את הסוויטה.
מקור: MongoDB Manual, "Project Fields to Return from Query".
"""
if not projection:
return doc
if isinstance(projection, (list, tuple)):
projection = {k: 1 for k in projection}
fields = {k: v for k, v in projection.items() if k != '_id'}
# מצב ההכללה נקבע לפי השדות שאינם ``_id``; כשיש רק ``_id`` — לפי הערך שלו.
# ``{_id: 1}`` לבדו מחזיר רק ``_id``, ו-``{_id: 1, f: 0}`` הוא החרגה, כי ``_id``
# הוא היוצא מן הכלל היחיד לאיסור על ערבוב. נמדד מול MongoDB 8.0.32.
inclusion = any(fields.values()) if fields else bool(projection.get('_id'))
if inclusion:
keep = {k for k, v in fields.items() if v} | ({'_id'} if projection.get('_id', 1) else set())
return {k: v for k, v in doc.items() if k in keep}
exclude = {k for k, v in projection.items() if not v}
return {k: v for k, v in doc.items() if k not in exclude}


class _StubColl:
def __init__(self):
self._docs = []
Expand All @@ -62,7 +87,7 @@ def find_one(self, query, projection=None, *args, sort=None, **kwargs):
if sort:
for key, direction in reversed(list(sort)):
docs.sort(key=lambda d: d.get(key), reverse=(direction < 0))
return docs[0] if docs else None
return _project(docs[0], projection) if docs else None

def count_documents(self, query, *args, **kwargs):
return len([d for d in self._docs if _matches(d, query)])
Expand Down Expand Up @@ -110,8 +135,9 @@ def find(self, query, projection=None, *args, **kwargs):
filtered = [d for d in self._docs if _matches(d, query)]

class _Cursor:
def __init__(self, items):
def __init__(self, items, projection=None):
self._items = list(items)
self._projection = projection
def sort(self, key=None, direction=1, **kw):
if key:
self._items.sort(key=lambda d: d.get(key), reverse=(direction < 0))
Expand All @@ -120,11 +146,12 @@ def limit(self, n):
self._items = self._items[:n]
return self
def __iter__(self):
return iter(self._items)
# המיון וה-limit רצים על המסמכים המלאים, כמו במונגו; ההיטלה חלה על מה שיוצא.
return iter([_project(d, self._projection) for d in self._items])
def __len__(self):
return len(self._items)

return _Cursor(filtered)
return _Cursor(filtered, projection)

def sort(self, *args, **kwargs):
return self
Expand Down Expand Up @@ -216,7 +243,7 @@ def test_summary_has_due(self):
data = r.get_json()
self.assertTrue(data['ok'])
self.assertTrue(data['has_due'])
self.assertIsNotNone(data['next'])
self.assertEqual(data['count_due'], 1)

def test_delete_reminder(self):
self._login()
Expand Down Expand Up @@ -291,6 +318,55 @@ def test_summary_reports_none_when_nothing_scheduled(self):
self.assertFalse(data['has_due'])
self.assertIsNone(data['next_in_seconds'])

# --- יש בועה: השרת עדיין אומר מתי לחזור ---

def _seed_upcoming(self, minutes, _id='r-future'):
self.db.note_reminders._docs.append({
'_id': _id, 'user_id': self.user_id, 'note_id': self.note_id, 'file_id': 'file-1',
'status': 'pending', 'ack_at': None,
'remind_at': datetime.now(timezone.utc) + timedelta(minutes=minutes),
})

def test_summary_with_a_due_reminder_says_when_to_come_back(self):
"""יש בועה ואין עתידית: השרת עונה "חזור בעוד חמש דקות", לא שותק.

לפני התיקון ``next_in_seconds`` חושב רק כשאין בשלות, והלקוח נרדם
לתקרה — חצי שעה שבה בועה שנוקתה ממכשיר אחר נשארה על המסך.
"""
self._login()
self._seed_due()
data = self.client.get('/api/sticky-notes/reminders/summary').get_json()
self.assertTrue(data['has_due'])
self.assertEqual(data['next_in_seconds'], 300)

def test_summary_with_a_due_reminder_wakes_for_the_next_maturity(self):
"""יש בועה ותזכורת נוספת בעוד שתי דקות: חוזרים כשהיא מבשילה, לא בעוד חמש."""
self._login()
self._seed_due()
self._seed_upcoming(2)
data = self.client.get('/api/sticky-notes/reminders/summary').get_json()
self.assertTrue(data['has_due'])
self.assertIsNotNone(data['next_in_seconds'])
self.assertGreater(data['next_in_seconds'], 2 * 60 - 60)
self.assertLessEqual(data['next_in_seconds'], 2 * 60)

def test_summary_with_a_due_reminder_caps_a_far_maturity_at_the_refresh(self):
"""יש בועה והעתידית רחוקה (45 דקות): הריענון של חמש דקות גובר."""
self._login()
self._seed_due()
self._seed_upcoming(45)
data = self.client.get('/api/sticky-notes/reminders/summary').get_json()
self.assertTrue(data['has_due'])
self.assertEqual(data['next_in_seconds'], 300)

def test_summary_no_longer_ships_the_unread_next_object(self):
"""``next`` ירד: אין לו צרכן, והשאילתה שבנתה אותו הייתה הסיבוב שיכול לא להסכים עם הספירה."""
self._login()
self._seed_due()
data = self.client.get('/api/sticky-notes/reminders/summary').get_json()
self.assertNotIn('next', data)
self.assertEqual(data['count_due'], 1)

def test_summary_fails_loudly_when_the_count_query_fails(self):
"""שאילתה שנכשלה אינה "אין תזכורות".

Expand Down Expand Up @@ -381,6 +457,34 @@ def test_list_returns_a_due_reminder_with_its_preview(self):
self.assertEqual(data['items'][0]['preview'], 'שלום עולם דביק')


class TestStubProjection(unittest.TestCase):
"""הסטאב מחיל היטלה כמו מונגו — חמשת המקרים נמדדו מול MongoDB 8.0.32 בייצור לפני הכתיבה.

``{_id: 1}`` לבדו הוא הכללה ומחזיר רק ``_id``; ``{_id: 1, f: 0}`` הוא החרגה,
כי ``_id`` הוא היוצא מן הכלל היחיד לאיסור על ערבוב הכללה והחרגה.
"""

DOC = {'_id': 'r1', 'remind_at': 'R', 'note_id': 'N'}

def _keys(self, projection):
return sorted(_project(dict(self.DOC), projection))

def test_id_only_is_an_inclusion(self):
self.assertEqual(self._keys({'_id': 1}), ['_id'])

def test_id_alongside_an_exclusion_stays_an_exclusion(self):
self.assertEqual(self._keys({'_id': 1, 'note_id': 0}), ['_id', 'remind_at'])

def test_excluding_id_keeps_the_rest(self):
self.assertEqual(self._keys({'_id': 0}), ['note_id', 'remind_at'])

def test_inclusion_can_drop_id(self):
self.assertEqual(self._keys({'remind_at': 1, '_id': 0}), ['remind_at'])

def test_inclusion_keeps_id_by_default(self):
self.assertEqual(self._keys({'remind_at': 1}), ['_id', 'remind_at'])


class TestReminderStateHelpers(unittest.TestCase):
"""המודול הטהור — בלי Flask ובלי מסד."""

Expand Down
32 changes: 30 additions & 2 deletions tests/test_sticky_reminders_polling_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ def _summary(**overrides):
"ok": True,
"has_due": False,
"count_due": 0,
"next": None,
"next_in_seconds": None,
}
reply.update(overrides)
Expand Down Expand Up @@ -246,6 +245,10 @@ def test_removing_the_clamp_breaks_the_long_delay(chromium_executable):
" window.__stickyRemindersBackoffUntil = Date.now() + backoffMs;\n"
" return backoffMs;"
)
DUE_USES_SERVER_DELAY = (
" // ייראו תוך כדי, ולא אחרי חצי שעה. שרת ישן שלא שולח ערך ← התקרה.\n"
" return requestedDelay(j);"
)
POPOVER_UNKNOWN = "openPopover(target, (lj && lj.ok) ? lj : { error: true });"
FLOOR_SKIPS_WHEN_STOPPED = "if (!stopped && Date.now() - lastPollAt < MIN_POLL_MS) { return; }"
LOG_ON_FAILURE = (
Expand Down Expand Up @@ -571,7 +574,7 @@ def test_a_catch_that_returns_the_ceiling_breaks_the_test(chromium_executable):

# --- החלונית: "לא ידוע" אינו "אין" ----------------------------------------------

_DUE = dict(has_due=True, count_due=2, next={"note_id": "n1", "file_id": "f1", "remind_at": None})
_DUE = dict(has_due=True, count_due=2)


def _click_bubble_and_read_popover(page):
Expand Down Expand Up @@ -980,3 +983,28 @@ def test_dropping_the_generation_bump_breaks_the_timeout_test(chromium_executabl
page.wait_for_timeout(200)
assert len(_delays(page)) == len(armed) + 1, (armed, _delays(page))
assert [t for _, t in sink if "poll failed" in t], sink


# --- יש בועה: השרת אומר מתי לחזור, והלקוח מקשיב ---------------------------------


def test_a_due_reminder_uses_the_servers_refresh_interval(chromium_executable):
"""יש בועה והשרת אמר לחזור בעוד חמש דקות: הטיימר הוא חמש דקות, לא התקרה.

לפני התיקון הענף של "יש בשלה" התעלם מ-next_in_seconds ונרדם לחצי שעה —
בועה שנוקתה ממכשיר אחר, או מונה שהשתנה, חיכו עד אז.
"""
reply = _summary(**_DUE, next_in_seconds=300)
with _chain(chromium_executable, _polling_script(), __status=200, __reply=reply) as page:
page.wait_for_function("window.__delays.length >= 1", timeout=8000)
assert _delays(page) == [300 * 1000], _delays(page)
assert page.evaluate("!!document.querySelector('.notif-bubble[data-kind=\"reminder\"]')") is True


def test_ignoring_the_interval_while_a_badge_is_up_breaks_the_test(chromium_executable):
"""ריצת בקרה: הענף חוזר להחזיר את התקרה — הטיימר הוא חצי שעה."""
script = _mutate(_polling_script(), DUE_USES_SERVER_DELAY, " return MAX_POLL_MS;")
reply = _summary(**_DUE, next_in_seconds=300)
with _chain(chromium_executable, script, __status=200, __reply=reply) as page:
page.wait_for_function("window.__delays.length >= 1", timeout=8000)
assert _delays(page) == [MAX_POLL_MS], _delays(page)
2 changes: 1 addition & 1 deletion webapp/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@

- סיכום לתצוגת באדג': GET `/api/sticky-notes/reminders/summary`

מחזיר `has_due` ו‑`next` (פתק הבא לפתיחה).
מחזיר `has_due`, `count_due` ו‑`next_in_seconds` (בעוד כמה שניות לשאול שוב; `null` כשאין מה לתזמן).

> הערה: התראות Web Push מלאות יגיעו באיטרציה נפרדת (Service Worker + VAPID).

Expand Down
Loading
Loading