From da65a6d4b8696a890afe8529381648e66fcfb59f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:03:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(reminders):=20=D7=94=D7=A9=D7=A8=D7=AA?= =?UTF-8?q?=20=D7=90=D7=95=D7=9E=D7=A8=20=D7=9E=D7=AA=D7=99=20=D7=9C=D7=97?= =?UTF-8?q?=D7=96=D7=95=D7=A8=20=D7=92=D7=9D=20=D7=9B=D7=A9=D7=99=D7=A9=20?= =?UTF-8?q?=D7=91=D7=95=D7=A2=D7=94,=20next=20=D7=99=D7=A8=D7=93=20=D7=A2?= =?UTF-8?q?=D7=9D=20=D7=94=D7=A9=D7=90=D7=99=D7=9C=D7=AA=D7=94=20=D7=A9?= =?UTF-8?q?=D7=9C=D7=95,=20=D7=95=D7=94=D7=A1=D7=98=D7=90=D7=91=20=D7=9E?= =?UTF-8?q?=D7=97=D7=99=D7=9C=20=D7=94=D7=99=D7=98=D7=9C=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שלב 3 בתוכנית התיקונים שאחרי הסקירה של #3430: WARN-004, WARN-011, YAGNI-001, ואיתם SUGG-001 (מדידה) ו-SUGG-008. WARN-004: כשיש תזכורת בשלה הלקוח נרדם לתקרה, חצי שעה, ובועה שנוקתה ממכשיר אחר או מונה שהשתנה חיכו עד אז. עכשיו next_in_seconds מחושב בשני המצבים, וכשיש בועה הוא נחתך ב-BADGE_REFRESH_SECONDS (חמש דקות, המרווח הקבוע הישן) או מוקדם יותר כשתזכורת נוספת מבשילה. הלקוח משתמש באותו חישוב בשני הענפים (requestedDelay), ו-null עדיין מתורגם לתקרה, כך ששרת ישן באמצע דיפלוי אינו משנה התנהגות. YAGNI-001: האובייקט next ירד מהתשובה יחד עם ה-find_one וההיטלה שבנו אותו, כי הצרכן היחיד (base.html) לא קרא אותו מעולם. WARN-011: אחרי ההסרה has_due ו-count_due נגזרים מהספירה בלבד, וה-find_one שנשאר שואל על העתיד, ולכן שתי התשובות אינן יכולות לשלוח בועה בלי תזכורת מאחוריה. בלי aggregate ובלי hint: אינדקס חסר היה הופך hint בשם ל-500 על נתיב שכל טאב מחובר דוגם. SUGG-008: הסטאב בטסטים מחיל היטלה כמו מונגו במקום לזרוק אותה. הראיה: היטלה שבורה בייצור עברה את הסוויטה עם הסטאב הישן ונופלת עם החדש. SUGG-001: explain לפני ואחרי מול הייצור. הספירה מכוסה על user_reminders_summary_idx; השאילתה העתידית עדיין בוחרת remind_at_idx עם FETCH שמסנן user_id, וצורתה אינה משתנה בקומיט הזה. המלצה להמשך בתיאור ה-PR. תיעוד: docs/dev/sticky_notes_extending.rst, docs/webapp/api-reference.rst, webapp/USER_GUIDE.md, וה-docstring של הנתיב (המשפט "נרדם עד הניווט הבא" הוחלף, חפיפה עם SUGG-012). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNRfYgfyQFv3Gk9UuXDvFx --- docs/dev/sticky_notes_extending.rst | 2 +- docs/webapp/api-reference.rst | 4 +- tests/test_sticky_note_reminders.py | 82 +++++++++++++++++-- .../test_sticky_reminders_polling_browser.py | 32 +++++++- webapp/USER_GUIDE.md | 2 +- webapp/sticky_notes_api.py | 54 ++++++------ webapp/templates/base.html | 15 +++- 7 files changed, 149 insertions(+), 42 deletions(-) diff --git a/docs/dev/sticky_notes_extending.rst b/docs/dev/sticky_notes_extending.rst index bb11df1e1..b29c984f3 100644 --- a/docs/dev/sticky_notes_extending.rst +++ b/docs/dev/sticky_notes_extending.rst @@ -87,7 +87,7 @@ הדגימה: כשל אינו "אין", ו-401 אינו כשל --------------------------------------------- -בלוק הדגימה ב-``base.html`` (``initStickyRemindersIndicator``) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו. +בלוק הדגימה ב-``base.html`` (``initStickyRemindersIndicator``) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. וכשיש בועה על המסך השרת עונה לכל היותר חמש דקות — ומוקדם יותר כשתזכורת נוספת מבשילה — כדי שניקוי ממכשיר אחר ומונה שמשתנה ייראו תוך כדי ולא אחרי חצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו. **כל כשל הוא "לא ידוע", ולכולם מונה אחד ו-backoff אחד.** תשובת שגיאה, שגיאת רשת, JSON פגום ו-timeout נכנסים כולם לאותה פונקציה: המונה עולה, ההמתנה מכפילה את עצמה מדקה עד התקרה, והיא נכתבת לאותו ``__stickyRemindersBackoffUntil`` שמשרת את 429 — כך ש-``visibilitychange`` מכבד אותה בלי לדעת עליה. הצלחה מאפסת את המונה; בלי האיפוס, מצב הדיכוי מהתקלה הקודמת היה מרעיל את החלון הבא. הגרסה הקודמת חזרה כל דקה לנצח על כל כשל, ונרדמה חצי שעה על שגיאת רשת — שני הקצוות הלא נכונים, ודווקא כשהשרת נופל. והבועה שכבר הוצגה נשארת: היא המצב האחרון הידוע, וכשל אינו ראיה שהתזכורות נעלמו; רק תשובה שאומרת "אין" ו-401 מסירים אותה. ‏429 אינו כשל ואינו "אין": השרת נוקב בעצמו בהמתנה (``Retry-After``, ולפחות רבע שעה), היא נכתבת לאותו חלון, המונה אינו זז, והבועה נשארת. וכל כשל נרשם בקונסול הדפדפן — מספר הכשל, ההמתנה הבאה והסיבה — כי כשל שקט נראה בדיוק כמו "אין תזכורות". diff --git a/docs/webapp/api-reference.rst b/docs/webapp/api-reference.rst index 9c159fb5f..3979dab81 100644 --- a/docs/webapp/api-reference.rst +++ b/docs/webapp/api-reference.rst @@ -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 מילים) וקישור לעוגן diff --git a/tests/test_sticky_note_reminders.py b/tests/test_sticky_note_reminders.py index 3463219e5..f963c8f6b 100644 --- a/tests/test_sticky_note_reminders.py +++ b/tests/test_sticky_note_reminders.py @@ -44,6 +44,27 @@ 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} + include = {k for k, v in projection.items() if v and k != '_id'} + if include: + keep = include | ({'_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 = [] @@ -62,7 +83,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)]) @@ -110,8 +131,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)) @@ -120,11 +142,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 @@ -216,7 +239,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() @@ -291,6 +314,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): """שאילתה שנכשלה אינה "אין תזכורות". diff --git a/tests/test_sticky_reminders_polling_browser.py b/tests/test_sticky_reminders_polling_browser.py index 41aa70fed..9c22eba65 100644 --- a/tests/test_sticky_reminders_polling_browser.py +++ b/tests/test_sticky_reminders_polling_browser.py @@ -87,7 +87,6 @@ def _summary(**overrides): "ok": True, "has_due": False, "count_due": 0, - "next": None, "next_in_seconds": None, } reply.update(overrides) @@ -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 = ( @@ -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): @@ -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) diff --git a/webapp/USER_GUIDE.md b/webapp/USER_GUIDE.md index 914ce277e..119df080f 100644 --- a/webapp/USER_GUIDE.md +++ b/webapp/USER_GUIDE.md @@ -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). diff --git a/webapp/sticky_notes_api.py b/webapp/sticky_notes_api.py index a0febe9c6..395c899ff 100644 --- a/webapp/sticky_notes_api.py +++ b/webapp/sticky_notes_api.py @@ -1127,6 +1127,13 @@ def snooze_note_reminder(note_id: str): return jsonify({'ok': False, 'error': 'Failed'}), 500 +#: כשיש בועה על המסך השרת מבקש מהלקוח לחזור לכל היותר בעוד חמש דקות — המרווח +#: הקבוע שהיה כאן לפני הדגימה לפי השרת, וזה שריענן גם ניקוי ממכשיר אחר וגם +#: מונה שהשתנה. הערך יושב בשרת ולא בלקוח כדי שללקוח יישאר כלל אחד: "ישן כמה +#: שהשרת אמר, בין הרצפה לתקרה". תזכורת נוספת שמבשילה מוקדם יותר גוברת עליו. +BADGE_REFRESH_SECONDS = 5 * 60 + + @sticky_notes_bp.route('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/reminders/summary', methods=['GET']) @require_auth @notes_rate_limit('note_reminders_summary', 300) @@ -1135,13 +1142,16 @@ def reminders_summary(): """Return minimal summary for persistent UI badge. Response: - { ok, has_due: bool, count_due: int, next: {...} | null, next_in_seconds: int | null } + { ok, has_due: bool, count_due: int, next_in_seconds: int | null } **``next_in_seconds`` הוא מה שמפסיק את הדגימה על ריק.** עד כאן הלקוח דגם כל חמש דקות בלי קשר למצב, כי "כן/לא" היה כל מה שקיבל — 954 קריאות ביממה שכולן החזירו "אין". השרת הוא היחיד שיודע גם *מתי* התזכורת הבאה, - ולכן הוא זה שאומר ללקוח מתי לחזור. אין תזכורת עתידית ← ``None``, והלקוח - נרדם עד הניווט הבא. + ולכן הוא זה שאומר ללקוח מתי לחזור — גם כשיש בועה: אז התשובה היא לכל + היותר ``BADGE_REFRESH_SECONDS``, כדי שבועה שנוקתה ממכשיר אחר ומונה + שהשתנה ייראו תוך דקות ולא אחרי חצי שעה. אין תזכורת עתידית ואין בועה ← + ``None``, והלקוח נרדם עד התקרה שלו (חצי שעה) — לא לנצח, כדי שתזכורת + שתיקבע ממכשיר אחר עדיין תתגלה. **והערך יחסי, לא חותמת.** ``remind_at`` שנקרא מהמסד הוא מודע-אזור רק כל עוד הלקוח נבנה עם ``tz_aware`` — ומחרוזת ISO בלי offset נקראת @@ -1156,9 +1166,6 @@ def reminders_summary(): base_filter = active_reminder_filter() base_filter['user_id'] = user_id due_filter = dict(base_filter, remind_at={'$lte': now}) - # רק שני השדות שהתשובה נושאת. הקוד הקודם משך את כל המסמכים המלאים - # ואז השתמש באורך הרשימה ובאיבר הראשון בלבד. - next_projection = {'note_id': 1, 'file_id': 1, 'remind_at': 1} # **שאילתה שנכשלה אינה "אין תזכורות".** כאן ישב ``except`` שהחזיר # ``count_due = 0``, והתשובה יצאה ``ok: true, has_due: false`` — # כלומר המשתמש קיבל "הכול נקי" על מסד שלא ענה. עכשיו החריגה עולה @@ -1167,33 +1174,26 @@ def reminders_summary(): # שנרדם לחצי שעה על סמך כשל הוא גרוע מלקוח שדוגם יותר מדי. count_due = int(db.note_reminders.count_documents(due_filter)) has_due = count_due > 0 - nxt = None + # מתי כדאי לשאול שוב — בשני המצבים. התזכורת העתידית הקרובה קובעת את + # המועד; כשיש בועה, התשובה נחתכת ב-BADGE_REFRESH_SECONDS, כי בועה שנוקתה + # ממכשיר אחר אינה שולחת שום אות, וחצי שעה של בועה ישנה היא בדיוק מה + # שהדגימה לפי השרת לא נועדה לייצר. זו השאילתה היחידה שנשארה לצד הספירה: + # היא שואלת על העתיד והספירה על ההווה, ולכן שתי התשובות אינן יכולות + # לסתור זו את זו — ``has_due`` נגזר מהספירה בלבד. + upcoming = db.note_reminders.find_one( + dict(base_filter, remind_at={'$gt': now}), + {'remind_at': 1}, + sort=[('remind_at', 1)], + ) + next_in_seconds = _seconds_until(upcoming.get('remind_at') if upcoming else None, now) if has_due: - first = db.note_reminders.find_one( - due_filter, next_projection, sort=[('remind_at', 1)] - ) - if first: - remind_at = first.get('remind_at') - nxt = { - 'note_id': str(first.get('note_id', '')), - 'file_id': str(first.get('file_id', '')), - 'remind_at': remind_at.isoformat() if isinstance(remind_at, datetime) else None, - } - # מתי כדאי לשאול שוב. כשכבר יש תזכורת בשלה הלקוח מציג אותה ואינו - # צריך מועד, ולכן השאילתה הזו רצה רק כשאין מה להציג. - next_in_seconds = None - if not has_due: - upcoming = db.note_reminders.find_one( - dict(base_filter, remind_at={'$gt': now}), - {'remind_at': 1}, - sort=[('remind_at', 1)], + next_in_seconds = ( + BADGE_REFRESH_SECONDS if next_in_seconds is None else min(BADGE_REFRESH_SECONDS, next_in_seconds) ) - next_in_seconds = _seconds_until(upcoming.get('remind_at') if upcoming else None, now) return jsonify({ 'ok': True, 'has_due': has_due, 'count_due': count_due, - 'next': nxt, 'next_in_seconds': next_in_seconds, }) except Exception: diff --git a/webapp/templates/base.html b/webapp/templates/base.html index 259db3449..504b2b6f1 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -4732,8 +4732,7 @@

removeDot(); // אין מה להציג. השרת אמר מתי התזכורת הבאה מבשילה; אין כזו — // נשארים בתקרה, כדי לגלות תזכורת שתיקבע ממכשיר אחר. - const nextIn = typeof j.next_in_seconds === 'number' ? j.next_in_seconds : null; - return nextIn === null ? MAX_POLL_MS : nextIn * 1000; + return requestedDelay(j); } const target = isMobile() ? document.querySelector('.mobile-menu-toggle') : document.querySelector('.nav-link[href="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/settings"]'); const bubble = ensureBubble(target); @@ -4765,10 +4764,18 @@

} }; bubble.onkeydown = (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); bubble.click(); } }; - // הבועה מוצגת. אין דחיפות לשאול שוב — המשתמש כבר רואה שיש לו משהו. - return MAX_POLL_MS; + // גם כשהבועה מוצגת השרת אומר מתי לחזור: לכל היותר חמש דקות, ומוקדם + // יותר כשתזכורת נוספת מבשילה — כדי שניקוי ממכשיר אחר ומונה שמשתנה + // ייראו תוך כדי, ולא אחרי חצי שעה. שרת ישן שלא שולח ערך ← התקרה. + return requestedDelay(j); } catch (e) { if (gen !== chainGen) { return staleAfterStop(); } return failAndBackOff(e); } } + // מה השרת ביקש, אם ביקש: מספר שניות, ובלעדיו התקרה. הרצפה והתקרה נאכפות + // ב-schedule. ערך שאינו מספר (שרת ישן, גוף חלקי) נחשב "לא ביקש". + function requestedDelay(j){ + const nextIn = typeof j.next_in_seconds === 'number' ? j.next_in_seconds : null; + return nextIn === null ? MAX_POLL_MS : nextIn * 1000; + } function schedule(ms){ try { if (pollTimer) { clearTimeout(pollTimer); } } catch(_) {} const requested = Number(ms); From ad63caabaf29654e926eadc0c90311af04d7ac96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:15:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(reminders):=20=D7=94=D7=A1=D7=98=D7=90?= =?UTF-8?q?=D7=91=20=D7=9E=D7=91=D7=97=D7=99=D7=9F=20=D7=91-{=5Fid:=201}?= =?UTF-8?q?=20=D7=9B=D7=94=D7=9B=D7=9C=D7=9C=D7=94,=20=D7=9B=D7=9E=D7=95?= =?UTF-8?q?=20=D7=9E=D7=95=D7=A0=D7=92=D7=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ממצא בסקירה של #3444: היטלה של _id בלבד החזירה בסטאב את המסמך המלא, כי מצב ההכללה נקבע רק לפי שדות שאינם _id. נמדד מול MongoDB 8.0.32 בייצור: {_id: 1} מחזיר רק _id; {_id: 1, f: 0} הוא החרגה (_id הוא היוצא מן הכלל היחיד לאיסור על ערבוב); {_id: 0} מחזיר את כל השאר. התיקון המוצע בסקירה היה שולח את המקרה השני להכללה, ולכן הכלל ממומש במלואו ולא הקיצור. טסט יחידה לעוזר עם חמשת המקרים: על הקוד הישן נופל {_id: 1}, ועל הקיצור המוצע נופל {_id: 1, f: 0}. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YNRfYgfyQFv3Gk9UuXDvFx --- tests/test_sticky_note_reminders.py | 38 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/test_sticky_note_reminders.py b/tests/test_sticky_note_reminders.py index f963c8f6b..2c9feae7a 100644 --- a/tests/test_sticky_note_reminders.py +++ b/tests/test_sticky_note_reminders.py @@ -57,9 +57,13 @@ def _project(doc, projection): return doc if isinstance(projection, (list, tuple)): projection = {k: 1 for k in projection} - include = {k for k, v in projection.items() if v and k != '_id'} - if include: - keep = include | ({'_id'} if projection.get('_id', 1) else set()) + 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} @@ -453,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 ובלי מסד."""