From ae643041a289ec0159982998b3d18b2466512c9c Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 17 Aug 2026 04:03:47 +0530 Subject: [PATCH 1/3] journal: add timeline date and drawing helpers Shared date math and drawing for the journal views. --- po/POTFILES.in | 1 + src/jarabe/journal/Makefile.am | 1 + src/jarabe/journal/timeline.py | 284 +++++++++++++++++++++++++++++++++ tests/journal/__init__.py | 0 tests/journal/test_timeline.py | 269 +++++++++++++++++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 src/jarabe/journal/timeline.py create mode 100644 tests/journal/__init__.py create mode 100644 tests/journal/test_timeline.py diff --git a/po/POTFILES.in b/po/POTFILES.in index 54a306c2ec..f1c5d7f872 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -78,6 +78,7 @@ src/jarabe/journal/volumestoolbar.py src/jarabe/journal/iconmodel.py src/jarabe/journal/iconview.py src/jarabe/journal/projectview.py +src/jarabe/journal/timeline.py src/jarabe/model/desktop.py src/jarabe/model/network.py src/jarabe/model/screenshot.py diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am index 31ce3b005a..07202ff85e 100644 --- a/src/jarabe/journal/Makefile.am +++ b/src/jarabe/journal/Makefile.am @@ -18,5 +18,6 @@ sugar_PYTHON = \ model.py \ objectchooser.py \ projectview.py \ + timeline.py \ palettes.py \ volumestoolbar.py diff --git a/src/jarabe/journal/timeline.py b/src/jarabe/journal/timeline.py new file mode 100644 index 0000000000..3090c73df2 --- /dev/null +++ b/src/jarabe/journal/timeline.py @@ -0,0 +1,284 @@ +# Copyright (C) 2026, Sugar Labs (Shubham Sharma) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import math +import time +from gettext import gettext as _ + +import cairo + +from sugar3 import profile +from sugar3.graphics import style + + +def safe_timestamp(value, default=0.0): + try: + number = float(value) + except (TypeError, ValueError): + return default + if not math.isfinite(number): + return default + try: + time.localtime(number) + except (ValueError, OverflowError, OSError): + return default + return number + + +def band_kind(timestamp): + if not timestamp: + return 'earlier' + hour = time.localtime(timestamp).tm_hour + if hour >= 21: + return 'night' + if hour >= 17: + return 'evening' + if hour >= 12: + return 'afternoon' + if hour >= 5: + return 'morning' + return 'early_hours' + + +def band_label(kind): + return { + 'night': _('night'), + 'evening': _('evening'), + 'afternoon': _('afternoon'), + 'morning': _('morning'), + 'early_hours': _('early hours'), + }.get(kind, '') + + +def day_label(day_ymd, now): + year, month, day = day_ymd + epoch = time.mktime((year, month, day, 12, 0, 0, 0, 0, -1)) + when = time.localtime(epoch) + + today = time.localtime(now) + if day_ymd == today[:3]: + return _('Today') + + today_epoch = time.mktime(today[:3] + (12, 0, 0, 0, 0, -1)) + days_ago = int(round((today_epoch - epoch) / 86400)) + if days_ago == 1: + return _('Yesterday') + if 0 <= days_ago <= 6: + return time.strftime('%A', when) + + year = '' if when.tm_year == today.tm_year else ' %d' % when.tm_year + return '%s, %d %s%s' % (time.strftime('%A', when), when.tm_mday, + time.strftime('%B', when), year) + + +def is_date_sort(order_by): + if not order_by: + return True + return order_by[0][1:] in ('timestamp', 'creation_time') + + +def sort_field(order_by): + if order_by and order_by[0][1:] == 'creation_time': + return 'creation_time' + return 'timestamp' + + +def hex_to_rgb(hex_color): + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + + +def hex_to_rgb01(hex_color): + return tuple(component / 255. for component in hex_to_rgb(hex_color)) + + +# These colours have no @define-color yet in sugar-artwork's gtk.css. +PAGE_BG = '#faf8f2' +DAY_INK = '#16150f' + +SPINE_COLOR = '#DDD4BD' + +SCROLLBAR_WIDTH = style.zoom(14) +SCROLLBAR_REST = '#d8d2c4' +SCROLLBAR_HOVER = '#b9b2a0' + +FOLD_TINT = '#f1ebda' +CONTROL_HOVER_TINT = '#e7dfcd' +CONTROL_PRESS_TINT = '#d6c9a8' + +META_INK = '#6b6558' + +# Not style.COLOR_INACTIVE_STROKE -- that's a disabled button's outline +# colour, not running text. +BAND_INK = '#7b7565' + + +_owner_stroke_color = None + + +def owner_stroke_color(): + # HACK: cache on first use, not at import, so accents don't each draw a + # different random pair when org.sugarlabs.user is unset. + global _owner_stroke_color + if _owner_stroke_color is None: + _owner_stroke_color = profile.get_color().get_stroke_color() + return _owner_stroke_color + + +BAND_SKY = { + 'early_hours': '#2E3B72', + 'morning': '#FFD98A', + 'afternoon': '#8FBEE8', + 'evening': '#E8896B', + 'night': '#0F1420', +} +BAND_BODY = { + 'early_hours': '#CFD6E8', + 'morning': '#FF8F00', + 'afternoon': '#FFC13B', + 'evening': '#9E3418', + 'night': '#F2E9C8', +} + +DAY_TITLE_SIZE = style.zoom(34) +BAND_NAME_SIZE = style.zoom(19) + +SWATCH_UNITS = 30. +SWATCH_INSET = 3 +SWATCH_SIZE = 24 +SWATCH_RADIUS = 7 + +GLYPH_SIZE = style.zoom(30) + +SWATCH_HALF = (SWATCH_SIZE / 2.) * (GLYPH_SIZE / SWATCH_UNITS) + +GLYPH_CLEARANCE = style.zoom(8) + + +def rounded_rect_path(cr, x, y, width, height, radius): + radius = min(radius, width / 2., height / 2.) + cr.new_sub_path() + cr.arc(x + width - radius, y + radius, radius, -0.5 * math.pi, 0) + cr.arc(x + width - radius, y + height - radius, radius, 0, 0.5 * math.pi) + cr.arc(x + radius, y + height - radius, radius, 0.5 * math.pi, math.pi) + cr.arc(x + radius, y + radius, radius, math.pi, 1.5 * math.pi) + cr.close_path() + + +def _draw_crescent(cr, ox, oy, color): + # HACK: even-odd fill turns solid here since the cut circle is + # bigger than the outer one; use two arcs instead. + radius = 12.4 + cut_radius, offset, tilt = 15.0, 9.7, math.radians(-38) + px = (offset * offset - cut_radius * cut_radius + + radius * radius) / (2 * offset) + py = math.sqrt(max(radius * radius - px * px, 0.)) + theta = math.atan2(py, px) + phi = math.atan2(py, px - offset) + + cr.save() + cr.translate(ox, oy) + cr.rotate(tilt) + cr.translate(-ox, -oy) + cr.new_path() + cr.arc(ox, oy, radius, theta, 2 * math.pi - theta) + cr.arc_negative(ox + offset, oy, cut_radius, -phi, phi - 2 * math.pi) + cr.close_path() + cr.set_source_rgb(*hex_to_rgb01(color)) + cr.fill() + cr.restore() + + +def draw_swatch(cr, kind, size): + if kind not in BAND_SKY: + return + cr.save() + cr.scale(size / SWATCH_UNITS, size / SWATCH_UNITS) + cr.set_antialias(cairo.ANTIALIAS_BEST) + rounded_rect_path(cr, SWATCH_INSET, SWATCH_INSET, + SWATCH_SIZE, SWATCH_SIZE, SWATCH_RADIUS) + cr.clip_preserve() + cr.set_source_rgb(*hex_to_rgb01(BAND_SKY[kind])) + cr.fill() + + body = BAND_BODY[kind] + if kind in ('early_hours', 'night'): + cr.save() + cr.translate(17.0, 12.5) + cr.scale(0.48, 0.48) + cr.translate(-15.0, -17.0) + _draw_crescent(cr, 15.0, 17.0, body) + cr.restore() + elif kind == 'afternoon': + cr.set_source_rgb(*hex_to_rgb01(body)) + cr.arc(15, 15, 6, 0, 2 * math.pi) + cr.fill() + else: + cr.new_sub_path() + cr.arc(15, 27, 6, math.pi, 2 * math.pi) + cr.close_path() + cr.set_source_rgb(*hex_to_rgb01(body)) + cr.fill() + cr.restore() + + +# Not folded into style.zoom(30): zoom() floors, so the two diverge. +PAGE_INSET = style.DEFAULT_SPACING * 2 + +SPINE_SLOT_WIDTH = style.zoom(78) +SPINE_WIDTH = style.zoom(3) + +# listview.py adds _CARD_LEFT_GAP and gridview.py adds _SHADOW_MARGIN +# beyond this. +COLUMN_WIDTH = PAGE_INSET + SPINE_SLOT_WIDTH + + +def glyph_left_in_slot(glyph_size=GLYPH_SIZE): + # Floored: the grid bead is a real widget and GTK only allocates it + # at an integer offset. + return (SPINE_SLOT_WIDTH - glyph_size) // 2 + + +def spine_centre_in_slot(glyph_size=GLYPH_SIZE): + # zoom() floors, so the two drift apart if derived separately. + return glyph_left_in_slot(glyph_size) + glyph_size / 2. + + +def bead_gap(centre, glyph_size=GLYPH_SIZE): + half = (SWATCH_SIZE / 2.) * (glyph_size / SWATCH_UNITS) + return (centre - half - GLYPH_CLEARANCE, + centre + half + GLYPH_CLEARANCE) + + +def draw_spine(cr, x, start, end, bead_centres, glyph_size=GLYPH_SIZE): + cr.set_source_rgb(*hex_to_rgb01(SPINE_COLOR)) + cr.set_line_width(SPINE_WIDTH) + y = start + for centre in bead_centres: + if centre is None: + continue + gap_top, gap_bottom = bead_gap(centre, glyph_size) + if gap_top > y: + cr.move_to(x, y) + cr.line_to(x, gap_top) + cr.stroke() + y = max(y, gap_bottom) + if end > y: + cr.move_to(x, y) + cr.line_to(x, end) + cr.stroke() + + +FOLD_MIN_CARDS = 2 diff --git a/tests/journal/__init__.py b/tests/journal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/journal/test_timeline.py b/tests/journal/test_timeline.py new file mode 100644 index 0000000000..f87a67030a --- /dev/null +++ b/tests/journal/test_timeline.py @@ -0,0 +1,269 @@ +# Copyright (C) 2026, Sugar Labs (Shubham Sharma) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import sys +import time +import unittest + +try: + # cairo and gi (pulled in transitively by jarabe.journal.timeline via + # sugar3.graphics.style) are apt-installed system packages, not + # something a bare venv necessarily has -- skip rather than error + # when they are not importable. + import cairo + import gi + assert gi +except (ImportError, ValueError): + raise unittest.SkipTest('gi is not available') + +# jarabe/__init__.py and jarabe/journal/__init__.py are both gi-free, but +# jarabe isn't on sys.path outside a built/installed tree. +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), '..', '..', 'src')) + +from jarabe.journal import timeline # noqa: E402 + + +def _at_hour(hour): + # band_kind reads time.localtime().tm_hour, so the fixed date only + # needs to avoid DST edges; the hour is what matters. + return time.mktime((2026, 8, 15, hour, 0, 0, 0, 0, -1)) + + +def _assert_bbox_approx(actual, expected, abs_tol=0.01): + for got, want in zip(actual, expected): + assert abs(got - want) <= abs_tol, (actual, expected) + + +# --- safe_timestamp --- + +class SafeTimestampTest(unittest.TestCase): + + def test_safe_timestamp_valid_number(self): + self.assertEqual(timeline.safe_timestamp(1234567890), 1234567890.0) + + def test_safe_timestamp_valid_numeric_string(self): + self.assertEqual(timeline.safe_timestamp('123.5'), 123.5) + + def test_safe_timestamp_none_uses_default(self): + self.assertEqual(timeline.safe_timestamp(None), 0.0) + self.assertEqual(timeline.safe_timestamp(None, default=42.0), 42.0) + + def test_safe_timestamp_nan_uses_default(self): + self.assertEqual(timeline.safe_timestamp(float('nan')), 0.0) + + def test_safe_timestamp_infinities_use_default(self): + self.assertEqual(timeline.safe_timestamp(float('inf')), 0.0) + self.assertEqual(timeline.safe_timestamp(float('-inf')), 0.0) + + def test_safe_timestamp_non_numeric_string_uses_default(self): + self.assertEqual(timeline.safe_timestamp('not-a-number'), 0.0) + + def test_safe_timestamp_out_of_localtime_range_uses_default(self): + # Far enough outside the epoch that time.localtime() raises + # (OverflowError on this platform); safe_timestamp must swallow it. + self.assertEqual(timeline.safe_timestamp(10 ** 18), 0.0) + + +# --- band_kind --- + +class BandKindTest(unittest.TestCase): + + # NOTE: the original test_band_kind_hour_boundaries was a single + # @pytest.mark.parametrize'd function over 10 (hour, expected) + # cases. pytest and unittest both count each parametrize case as + # its own collected test, but a self.subTest() loop does not -- + # it stays one collected test under unittest and reports as + # "N passed, 10 subtests passed" under pytest, so it cannot hit + # the required 41/35/15 counts. Expanded to one method per case, + # named for its (hour, expected) pair to keep the split traceable + # back to the original parametrize table. + + def test_band_kind_hour_boundaries_00_early_hours(self): + self.assertEqual(timeline.band_kind(_at_hour(0)), 'early_hours') + + def test_band_kind_hour_boundaries_04_early_hours(self): + self.assertEqual(timeline.band_kind(_at_hour(4)), 'early_hours') + + def test_band_kind_hour_boundaries_05_morning(self): + self.assertEqual(timeline.band_kind(_at_hour(5)), 'morning') + + def test_band_kind_hour_boundaries_11_morning(self): + self.assertEqual(timeline.band_kind(_at_hour(11)), 'morning') + + def test_band_kind_hour_boundaries_12_afternoon(self): + self.assertEqual(timeline.band_kind(_at_hour(12)), 'afternoon') + + def test_band_kind_hour_boundaries_16_afternoon(self): + self.assertEqual(timeline.band_kind(_at_hour(16)), 'afternoon') + + def test_band_kind_hour_boundaries_17_evening(self): + self.assertEqual(timeline.band_kind(_at_hour(17)), 'evening') + + def test_band_kind_hour_boundaries_20_evening(self): + self.assertEqual(timeline.band_kind(_at_hour(20)), 'evening') + + def test_band_kind_hour_boundaries_21_night(self): + self.assertEqual(timeline.band_kind(_at_hour(21)), 'night') + + def test_band_kind_hour_boundaries_23_night(self): + self.assertEqual(timeline.band_kind(_at_hour(23)), 'night') + + def test_band_kind_falsy_timestamp_is_earlier(self): + self.assertEqual(timeline.band_kind(0), 'earlier') + self.assertEqual(timeline.band_kind(None), 'earlier') + + +# --- day_label --- +# +# Expected strings are derived the same way timeline.day_label itself +# builds them (gettext for Today/Yesterday, time.strftime for the rest) +# rather than hardcoded English, so these don't depend on the LC_TIME +# locale or on gettext translations being bound in the process. + +def _weekday_only(day_ymd): + epoch = time.mktime(day_ymd + (12, 0, 0, 0, 0, -1)) + return time.strftime('%A', time.localtime(epoch)) + + +def _full_date(day_ymd, with_year): + epoch = time.mktime(day_ymd + (12, 0, 0, 0, 0, -1)) + when = time.localtime(epoch) + if with_year: + return '%s, %d %s %d' % (time.strftime('%A', when), when.tm_mday, + time.strftime('%B', when), when.tm_year) + return '%s, %d %s' % (time.strftime('%A', when), when.tm_mday, + time.strftime('%B', when)) + + +class DayLabelTest(unittest.TestCase): + + def test_day_label_today(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + self.assertEqual( + timeline.day_label((2026, 8, 15), now), timeline._('Today')) + + def test_day_label_yesterday(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + self.assertEqual( + timeline.day_label((2026, 8, 14), now), timeline._('Yesterday')) + + def test_day_label_recent_weekday_name(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + # 2026-08-12 is 3 days back -- inside the bare-weekday window. + day = (2026, 8, 12) + self.assertEqual(timeline.day_label(day, now), _weekday_only(day)) + + def test_day_label_weekday_boundary_at_six_days(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + # 6 days back -- still inside the bare-weekday window (<= 6). + day = (2026, 8, 9) + self.assertEqual(timeline.day_label(day, now), _weekday_only(day)) + + def test_day_label_full_date_boundary_at_seven_days(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + # 7 days back -- just past the bare-weekday window (<= 6). + day = (2026, 8, 8) + self.assertEqual( + timeline.day_label(day, now), _full_date(day, with_year=False)) + + def test_day_label_full_date_same_year(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + # 10 days back, same year -- past the weekday-only window (<= 6). + day = (2026, 8, 5) + self.assertEqual( + timeline.day_label(day, now), _full_date(day, with_year=False)) + + def test_day_label_full_date_with_year_when_year_differs(self): + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + day = (2025, 7, 11) + self.assertEqual( + timeline.day_label(day, now), _full_date(day, with_year=True)) + + def test_day_label_future_timestamp_gets_a_full_date(self): + # A clock-skewed future date must not pass as a nearby weekday. + now = time.mktime((2026, 8, 15, 12, 0, 0, 0, 0, -1)) + tomorrow = time.localtime(now + 86400)[:3] + far_future = time.localtime(now + 400 * 86400)[:3] + self.assertEqual(timeline.day_label(tomorrow, now), _full_date( + tomorrow, with_year=False)) + self.assertEqual(timeline.day_label(far_future, now), _full_date( + far_future, with_year=True)) + + +# --- is_date_sort / sort_field --- + +class IsDateSortSortFieldTest(unittest.TestCase): + + def test_is_date_sort_defaults_true_when_unset(self): + self.assertTrue(timeline.is_date_sort(None)) + self.assertTrue(timeline.is_date_sort([])) + + def test_is_date_sort_true_for_timestamp_and_creation_time(self): + self.assertTrue(timeline.is_date_sort(['-timestamp'])) + self.assertTrue(timeline.is_date_sort(['+creation_time'])) + + def test_is_date_sort_false_for_other_fields(self): + self.assertFalse(timeline.is_date_sort(['-filesize'])) + + def test_sort_field_defaults_to_timestamp(self): + self.assertEqual(timeline.sort_field(None), 'timestamp') + self.assertEqual(timeline.sort_field(['-timestamp']), 'timestamp') + self.assertEqual(timeline.sort_field(['-filesize']), 'timestamp') + + def test_sort_field_creation_time(self): + self.assertEqual( + timeline.sort_field(['-creation_time']), 'creation_time') + self.assertEqual( + timeline.sort_field(['+creation_time']), 'creation_time') + + +# --- hex color helpers --- + +class HexColorHelpersTest(unittest.TestCase): + + def test_hex_to_rgb_with_and_without_hash(self): + self.assertEqual(timeline.hex_to_rgb('#FF0080'), (255, 0, 128)) + self.assertEqual(timeline.hex_to_rgb('FF0080'), (255, 0, 128)) + + def test_hex_to_rgb01_normalizes_to_unit_range(self): + self.assertEqual(timeline.hex_to_rgb01('#FFFFFF'), (1.0, 1.0, 1.0)) + self.assertEqual(timeline.hex_to_rgb01('#000000'), (0.0, 0.0, 0.0)) + + +# --- rounded_rect_path geometry (ImageSurface, no display needed) --- + +class RoundedRectPathTest(unittest.TestCase): + + def test_rounded_rect_path_bounding_box(self): + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 100, 100) + cr = cairo.Context(surface) + timeline.rounded_rect_path(cr, 10, 10, 40, 20, 5) + x0, y0, x1, y1 = cr.path_extents() + _assert_bbox_approx((x0, y0, x1, y1), (10, 10, 50, 30)) + + def test_rounded_rect_path_clamps_radius_to_half_extent(self): + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 100, 100) + cr = cairo.Context(surface) + # radius far larger than either half-dimension must clamp rather + # than distort or overflow the requested rectangle. + timeline.rounded_rect_path(cr, 0, 0, 10, 20, 999) + x0, y0, x1, y1 = cr.path_extents() + _assert_bbox_approx((x0, y0, x1, y1), (0, 0, 10, 20)) + + +if __name__ == '__main__': + unittest.main() From d5348d2f5b5fbc90ef89950b1329614aa223e5a7 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 17 Aug 2026 04:03:47 +0530 Subject: [PATCH 2/3] journal: key journal entries by sitting One sitting groups the entries a child made in one stretch of work in the same activity. --- src/jarabe/journal/misc.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py index ee3cbec18e..7dc30040be 100644 --- a/src/jarabe/journal/misc.py +++ b/src/jarabe/journal/misc.py @@ -118,6 +118,11 @@ def get_icon_name(metadata): return file_name +def get_sitting_key(metadata): + """Key identifying which sitting an entry belongs to.""" + return metadata.get('activity', '') or get_icon_name(metadata) + + def get_date(metadata): """ Convert from a string in iso format to a more human-like format. """ if 'timestamp' in metadata: From 35a160dccd6e1f36095b15e25a9f348c9ff84ec7 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 17 Aug 2026 04:03:47 +0530 Subject: [PATCH 3/3] journal: cache row facts in the list model Views ask for uid, timestamps and sitting key on every draw; answering from a cache avoids a result-set read per cell. Dates now run through timeline.safe_timestamp: zero or invalid timestamps render 'No date' instead of an 'Unknown' elapsed string, and nan/inf no longer raise. --- src/jarabe/journal/listmodel.py | 62 +++- tests/journal/test_listmodel.py | 587 ++++++++++++++++++++++++++++++++ 2 files changed, 636 insertions(+), 13 deletions(-) create mode 100644 tests/journal/test_listmodel.py diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py index 0a9e90ffe8..00f260198e 100644 --- a/src/jarabe/journal/listmodel.py +++ b/src/jarabe/journal/listmodel.py @@ -27,6 +27,7 @@ from jarabe.journal import model from jarabe.journal import misc +from jarabe.journal import timeline DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' @@ -78,6 +79,7 @@ def __init__(self, query): self._last_requested_index = None self._temp_drag_file_uid = None self._cached_row = None + self._row_facts = {} self._query = query self._all_ids = [] t = time.time() @@ -139,9 +141,10 @@ def set_value(self, iterator, column, value): if column == ListModel.COLUMN_TITLE: metadata['title'] = value self._updated_entries[metadata['uid']] = metadata - # The edit must survive a re-read of this same row: the row - # cache was primed by the read that preceded this write. + # The edit must survive a re-read of this same row: both row + # caches were primed by the read that preceded this write. self._last_requested_index = None + self._row_facts.pop(index, None) if self._updated_callback is not None: model.updated.disconnect(self._updated_callback) model.write(metadata, update_mtime=False, @@ -151,6 +154,35 @@ def __reconnect_updates_cb(self, metadata, filepath, uid): if self._updated_callback is not None: model.updated.connect(self._updated_callback) + def get_row_facts(self, index): + facts = self._row_facts.get(index) + if facts is not None: + return facts + if self.view_is_resizing or index >= self._result_set.length: + return None + self._result_set.seek(index) + metadata = self._result_set.read() + metadata.update(self._updated_entries.get(metadata['uid'], {})) + return self._remember_row_facts(index, metadata) + + def get_row_metadata(self, index): + if self.view_is_resizing or index >= self._result_set.length: + return None + self._result_set.seek(index) + metadata = self._result_set.read() + metadata.update(self._updated_entries.get(metadata['uid'], {})) + return dict(metadata) + + def _remember_row_facts(self, index, metadata): + timestamp = timeline.safe_timestamp(metadata.get('timestamp', 0)) + # sugar-datastore backfills creation_time = timestamp on create/update. + creation_time = timeline.safe_timestamp( + metadata.get('creation_time', timestamp), default=timestamp) + facts = (metadata['uid'], timestamp, misc.get_sitting_key(metadata), + creation_time) + self._row_facts[index] = facts + return facts + def do_get_value(self, iterator, column): if self.view_is_resizing: return None @@ -165,6 +197,7 @@ def do_get_value(self, iterator, column): self._result_set.seek(index) metadata = self._result_set.read() metadata.update(self._updated_entries.get(metadata['uid'], {})) + facts = self._remember_row_facts(index, metadata) row = [] row.append(metadata['uid']) @@ -188,21 +221,24 @@ def do_get_value(self, iterator, column): title = GObject.markup_escape_text(title_value) row.append('%s' % (title, )) - try: - timestamp = float(metadata.get('timestamp', 0)) - except (TypeError, ValueError): - timestamp_content = _('Unknown') - else: + # A bare float() lets 'nan' and 'inf' through as truthy; + # util.timestamp_to_elapsed_string raises ValueError on + # both, since int() can't convert a float NaN to an + # integer. A falsy timestamp shows the same "no date" + # string as expandedentry.py's _format_date, instead of a + # false elapsed time. + timestamp = facts[1] + if timestamp: timestamp_content = util.timestamp_to_elapsed_string(timestamp) + else: + timestamp_content = _('No date') row.append(timestamp_content) - try: - creation_time = float(metadata.get('creation_time')) - except (TypeError, ValueError): - row.append(_('Unknown')) + creation_time = facts[3] + if creation_time: + row.append(util.timestamp_to_elapsed_string(creation_time)) else: - row.append( - util.timestamp_to_elapsed_string(float(creation_time))) + row.append(_('No date')) try: size = int(metadata.get('filesize')) diff --git a/tests/journal/test_listmodel.py b/tests/journal/test_listmodel.py new file mode 100644 index 0000000000..b9196c5a1c --- /dev/null +++ b/tests/journal/test_listmodel.py @@ -0,0 +1,587 @@ +# Copyright (C) 2026, Sugar Labs (Shubham Sharma) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import os +import sys +import unittest +from gettext import gettext as _ +from types import SimpleNamespace +from unittest import mock + +try: + # gi is an apt-installed system package, not something `uvx pytest`'s + # own managed interpreter has -- skip rather than error when it isn't + # importable. + import gi + + # Gtk/Gdk must be pinned to 3.0 before jarabe.journal.listmodel pulls + # them in bare, or PyGObject resolves Gdk to 4.0 first and the later + # Gtk 3.0 import conflicts with it. + gi.require_version('Gtk', '3.0') + gi.require_version('Gdk', '3.0') +except (ImportError, ValueError): + raise unittest.SkipTest('gi is not available') + +# jarabe isn't on sys.path outside a built/installed tree. +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), '..', '..', 'src')) + +from jarabe.journal import listmodel # noqa: E402 +from jarabe.journal import misc # noqa: E402 +from jarabe.journal import model # noqa: E402 + + +class _Signal: + """Stand-in for the ready/progress dispatch.Signal a real + ResultSet carries -- ListModel.__init__ only needs .connect() to + exist on it, it never has to actually fire in these tests.""" + + def connect(self, callback): + pass + + +class _FakeResultSet: + + def __init__(self, entries): + self.entries = entries + self.length = len(entries) + self.position = None + self.seek_calls = [] + self.ready = _Signal() + self.progress = _Signal() + + def seek(self, index): + self.seek_calls.append(index) + self.position = index + + def read(self): + return dict(self.entries[self.position]) + + def find_ids(self, query): + return [entry['uid'] for entry in self.entries] + + def setup(self): + pass + + def stop(self): + pass + + +def _make_model(monkeypatch, entries): + fake_rs = _FakeResultSet(entries) + monkeypatch.setattr(model, 'find', lambda query, page_size: fake_rs) + return listmodel.ListModel({}), fake_rs + + +def _iter_at(index): + return SimpleNamespace(user_data=index) + + +class _MonkeyPatch: + """Minimal replacement for pytest's monkeypatch fixture: same + .setattr(target, name, value) interface, but cleaned up through + the owning TestCase's addCleanup instead of fixture teardown.""" + + def __init__(self, testcase): + self._patchers = [] + testcase.addCleanup(self._undo) + + def setattr(self, target, name, value): + patcher = mock.patch.object(target, name, value) + patcher.start() + self._patchers.append(patcher) + + def _undo(self): + for patcher in reversed(self._patchers): + patcher.stop() + + +def stub_misc(monkeypatch): + # get_icon_name/is_activity_bundle/get_icon_color reach into + # bundleregistry and the datastore -- stub them at the misc + # boundary so do_get_value's own branching is what's under test. + monkeypatch.setattr(misc, 'get_icon_name', lambda metadata: 'icon-name') + monkeypatch.setattr(misc, 'is_activity_bundle', lambda metadata: False) + monkeypatch.setattr( + misc, 'get_icon_color', lambda metadata: 'icon-color-sentinel') + + +def stub_elapsed(monkeypatch): + # timestamp_to_elapsed_string keys its i18n cache off + # os.environ['LANG'] -- stub it so timestamp columns are + # deterministic without depending on the runner's locale. + monkeypatch.setattr( + listmodel.util, 'timestamp_to_elapsed_string', + lambda ts: 'elapsed:%s' % ts) + + +# --- _remember_row_facts: timestamp/creation_time fallbacks, sitting key --- + +class RememberRowFactsTest(unittest.TestCase): + + def test_remember_row_facts_missing_timestamp_defaults_to_zero(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + facts = lm._remember_row_facts( + 0, {'uid': 'a', 'activity': 'org.laptop.Foo'}) + self.assertEqual(facts, ('a', 0.0, 'org.laptop.Foo', 0.0)) + + def test_remember_row_facts_missing_creation_time_reuses_timestamp( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + facts = lm._remember_row_facts( + 0, {'uid': 'a', 'timestamp': 1000, 'activity': 'org.laptop.Foo'}) + self.assertEqual(facts[1], 1000.0) + self.assertEqual(facts[3], 1000.0) + + def test_remember_row_facts_invalid_creation_time_falls_back_to_parsed_ts( # noqa: E501 + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + facts = lm._remember_row_facts( + 0, {'uid': 'a', 'timestamp': 1000, 'creation_time': 'garbage', + 'activity': 'org.laptop.Foo'}) + # safe_timestamp's `default` for creation_time is the already-parsed + # timestamp fact (1000.0), not the raw metadata value -- an + # unparseable creation_time reuses the derived fact rather than + # re-deriving anything from scratch. + self.assertEqual(facts[3], 1000.0) + + def test_remember_row_facts_valid_creation_time_is_used_verbatim( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + facts = lm._remember_row_facts( + 0, {'uid': 'a', 'timestamp': 1000, 'creation_time': 2000, + 'activity': 'org.laptop.Foo'}) + self.assertEqual(facts[3], 2000.0) + + def test_remember_row_facts_sitting_key_prefers_activity_over_icon_lookup( # noqa: E501 + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + + def boom(metadata): + raise AssertionError('get_icon_name ran despite activity present') + monkeypatch.setattr(misc, 'get_icon_name', boom) + + facts = lm._remember_row_facts( + 0, {'uid': 'a', 'activity': 'org.laptop.Foo'}) + self.assertEqual(facts[2], 'org.laptop.Foo') + + def test_remember_row_facts_sitting_key_falls_back_to_icon_name( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model(monkeypatch, []) + facts = lm._remember_row_facts(0, {'uid': 'a'}) + self.assertEqual(facts[2], 'icon-name') + + def test_remember_row_facts_stores_into_the_row_facts_cache(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + facts = lm._remember_row_facts(3, {'uid': 'a', 'activity': 'x'}) + self.assertEqual(lm._row_facts[3], facts) + + +# --- get_row_facts: cache short-circuit, guards, updated-entry merge --- + +class GetRowFactsTest(unittest.TestCase): + + def test_get_row_facts_returns_none_when_view_is_resizing(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'activity': 'x'}]) + lm.view_is_resizing = True + self.assertIsNone(lm.get_row_facts(0)) + + def test_get_row_facts_returns_none_for_out_of_range_index(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'activity': 'x'}]) + self.assertIsNone(lm.get_row_facts(1)) + + def test_get_row_facts_reads_through_and_caches(self): + monkeypatch = _MonkeyPatch(self) + lm, rs = _make_model( + monkeypatch, [{'uid': 'a', 'timestamp': 1000, 'activity': 'x'}]) + facts = lm.get_row_facts(0) + self.assertEqual(facts, ('a', 1000.0, 'x', 1000.0)) + self.assertEqual(rs.seek_calls, [0]) + + def test_get_row_facts_second_call_hits_cache_not_the_result_set( + self): + monkeypatch = _MonkeyPatch(self) + lm, rs = _make_model( + monkeypatch, [{'uid': 'a', 'timestamp': 1000, 'activity': 'x'}]) + lm.get_row_facts(0) + lm.get_row_facts(0) + self.assertEqual(rs.seek_calls, [0]) + + def test_get_row_facts_cache_hit_wins_even_while_view_is_resizing( + self): + monkeypatch = _MonkeyPatch(self) + lm, rs = _make_model( + monkeypatch, [{'uid': 'a', 'timestamp': 1000, 'activity': 'x'}]) + lm.get_row_facts(0) + lm.view_is_resizing = True + seeks_before = list(rs.seek_calls) + facts = lm.get_row_facts(0) + self.assertEqual(facts, ('a', 1000.0, 'x', 1000.0)) + self.assertEqual(rs.seek_calls, seeks_before) + + def test_get_row_facts_merges_updated_entries_over_the_stored_read( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model( + monkeypatch, [{'uid': 'a', 'timestamp': 1000, 'activity': 'x'}]) + lm._updated_entries['a'] = {'timestamp': 5000} + facts = lm.get_row_facts(0) + self.assertEqual(facts[1], 5000.0) + + def test_get_row_facts_cache_is_evicted_by_set_value(self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + # An edit through set_value drops the edited index from the + # row-facts cache, so the next read re-derives from the merged + # entry. Direct _updated_entries writes without set_value still + # leave a populated slot alone (nothing else evicts it). + lm, _rs = _make_model( + monkeypatch, [{'uid': 'a', 'timestamp': 1000, 'activity': 'x', + 'title': 'old'}]) + lm.setup(updated_callback=None) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + lm.get_row_facts(0) + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_TITLE, 'new') + self.assertEqual(lm.get_row_metadata(0)['title'], 'new') + + +# --- get_row_metadata: same guards, but never touches the facts cache --- + +class GetRowMetadataTest(unittest.TestCase): + + def test_get_row_metadata_returns_none_when_view_is_resizing(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a'}]) + lm.view_is_resizing = True + self.assertIsNone(lm.get_row_metadata(0)) + + def test_get_row_metadata_returns_none_for_out_of_range_index(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a'}]) + self.assertIsNone(lm.get_row_metadata(1)) + + def test_get_row_metadata_merges_updated_entries(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'original'}]) + lm._updated_entries['a'] = {'title': 'edited'} + metadata = lm.get_row_metadata(0) + self.assertEqual(metadata['title'], 'edited') + + def test_get_row_metadata_returns_a_fresh_copy(self): + monkeypatch = _MonkeyPatch(self) + lm, rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'original'}]) + metadata = lm.get_row_metadata(0) + metadata['title'] = 'mutated locally' + self.assertEqual(rs.entries[0]['title'], 'original') + + def test_get_row_metadata_does_not_populate_the_row_facts_cache( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a'}]) + lm.get_row_metadata(0) + self.assertNotIn(0, lm._row_facts) + + +# --- do_get_value: guarded branches on a mocked result set --- + +class DoGetValueTest(unittest.TestCase): + + def test_do_get_value_returns_none_when_view_is_resizing( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'activity': 'x'}]) + lm.view_is_resizing = True + column = listmodel.ListModel.COLUMN_UID + self.assertIsNone(lm.do_get_value(_iter_at(0), column)) + + def test_do_get_value_returns_none_for_out_of_range_index( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'activity': 'x'}]) + column = listmodel.ListModel.COLUMN_UID + self.assertIsNone(lm.do_get_value(_iter_at(5), column)) + + def test_do_get_value_title_non_str_guard_falls_back_to_untitled( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model( + monkeypatch, [{'uid': 'a', 'activity': 'x', 'title': 42}]) + column = listmodel.ListModel.COLUMN_TITLE + with self.assertLogs(level=logging.WARNING) as cm: + title = lm.do_get_value(_iter_at(0), column) + self.assertEqual(title, '%s' % _('Untitled')) + self.assertIn('is not a string', '\n'.join(cm.output)) + + def test_do_get_value_timestamp_falsy_shows_no_date(self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'activity': 'x'}]) + column = listmodel.ListModel.COLUMN_TIMESTAMP + self.assertEqual(lm.do_get_value(_iter_at(0), column), _('No date')) + + def test_do_get_value_timestamp_present_shows_elapsed_string( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + stub_elapsed(monkeypatch) + lm, _rs = _make_model( + monkeypatch, + [{'uid': 'a', 'activity': 'x', 'timestamp': 1000}]) + column = listmodel.ListModel.COLUMN_TIMESTAMP + self.assertEqual( + lm.do_get_value(_iter_at(0), column), 'elapsed:1000.0') + + def test_do_get_value_creation_time_falsy_shows_no_date( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model(monkeypatch, [ + {'uid': 'a', 'activity': 'x', 'timestamp': 0, + 'creation_time': 'garbage'}]) + column = listmodel.ListModel.COLUMN_CREATION_TIME + self.assertEqual(lm.do_get_value(_iter_at(0), column), _('No date')) + + def test_do_get_value_creation_time_reuses_the_parsed_timestamp_fact( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + stub_elapsed(monkeypatch) + lm, _rs = _make_model( + monkeypatch, + [{'uid': 'a', 'activity': 'x', 'timestamp': 1000}]) + timestamp_col = lm.do_get_value( + _iter_at(0), listmodel.ListModel.COLUMN_TIMESTAMP) + creation_col = lm.do_get_value( + _iter_at(0), listmodel.ListModel.COLUMN_CREATION_TIME) + self.assertEqual(timestamp_col, creation_col) + self.assertEqual(creation_col, 'elapsed:1000.0') + + def test_do_get_value_populates_the_row_facts_cache_as_a_side_effect( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model( + monkeypatch, [{'uid': 'a', 'activity': 'x', 'timestamp': 1000}]) + lm.do_get_value(_iter_at(0), listmodel.ListModel.COLUMN_UID) + self.assertEqual(lm._row_facts[0], ('a', 1000.0, 'x', 1000.0)) + + def test_do_get_value_reuses_the_last_requested_index_without_reseeking( + self): + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + stub_elapsed(monkeypatch) + lm, rs = _make_model( + monkeypatch, + [{'uid': 'a', 'activity': 'x', 'timestamp': 1000}]) + lm.do_get_value(_iter_at(0), listmodel.ListModel.COLUMN_UID) + seeks_before = list(rs.seek_calls) + value = lm.do_get_value( + _iter_at(0), listmodel.ListModel.COLUMN_TIMESTAMP) + self.assertEqual(rs.seek_calls, seeks_before) + self.assertEqual(value, 'elapsed:1000.0') + + +# --- selection state --- + +class SelectionStateTest(unittest.TestCase): + + def test_is_selected_false_for_an_unselected_uid(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + self.assertIs(lm.is_selected('a'), False) + + def test_set_selected_true_then_is_selected_true(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + lm.set_selected('a', True) + self.assertIs(lm.is_selected('a'), True) + + def test_set_selected_false_removes_a_previously_selected_uid(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + lm.set_selected('a', True) + lm.set_selected('a', False) + self.assertIs(lm.is_selected('a'), False) + + def test_set_selected_false_on_a_never_selected_uid_raises(self): + # CURRENT BEHAVIOR: set_selected(uid, False) calls + # self._selected.remove(uid) unconditionally -- there is no + # `if uid in self._selected` guard. Every call site today + # (listview.py, gridview.py, journaltoolbox.py, palettes.py) only + # ever deselects a uid it has just confirmed is selected, so this + # never fires in practice, but the method itself has no internal + # guard: deselecting (or double-deselecting) a uid that isn't + # currently selected raises ValueError instead of being a no-op. + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + with self.assertRaises(ValueError): + lm.set_selected('never-selected', False) + + def test_get_selected_items_returns_the_live_selection_list(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + lm.set_selected('a', True) + lm.set_selected('b', True) + self.assertEqual(lm.get_selected_items(), ['a', 'b']) + + def test_restore_selection_replaces_the_current_selection(self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, []) + lm.set_selected('a', True) + lm.restore_selection(['x', 'y']) + self.assertEqual(lm.get_selected_items(), ['x', 'y']) + self.assertIs(lm.is_selected('a'), False) + + +# --- set_value: the updated-signal disconnect/reconnect around write --- + +class _RecordingSignal: + + def __init__(self): + self.calls = [] + + def connect(self, callback): + self.calls.append(('connect', callback)) + + def disconnect(self, callback): + self.calls.append(('disconnect', callback)) + + +def _noop_callback(*args, **kwargs): + pass + + +class SetValueTest(unittest.TestCase): + + def test_set_value_favorite_column_updates_keep_and_records_entry( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'keep': '0'}]) + lm.setup(updated_callback=None) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_FAVORITE, True) + self.assertIs(lm._updated_entries['a']['keep'], True) + + def test_set_value_title_column_updates_title_and_records_entry( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'old'}]) + lm.setup(updated_callback=None) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_TITLE, 'new') + self.assertEqual(lm._updated_entries['a']['title'], 'new') + + def test_set_value_other_column_still_records_the_entry_unchanged( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'old'}]) + lm.setup(updated_callback=None) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_UID, 'ignored') + self.assertEqual( + lm._updated_entries['a'], {'uid': 'a', 'title': 'old'}) + + def test_set_value_disconnects_before_write_and_reconnects_after( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'old'}]) + lm.setup(updated_callback=_noop_callback) + + recording = _RecordingSignal() + monkeypatch.setattr(model, 'updated', recording) + + def fake_write(metadata, update_mtime=False, ready_callback=None): + # the disconnect must already have happened by the time write + # fires, and the reconnect must not have happened yet. + self.assertEqual(recording.calls, [('disconnect', _noop_callback)]) + ready_callback(metadata, 'filepath', metadata['uid']) + + monkeypatch.setattr(model, 'write', fake_write) + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_TITLE, 'new') + + self.assertEqual(recording.calls, [ + ('disconnect', _noop_callback), ('connect', _noop_callback)]) + + def test_set_value_skips_disconnect_reconnect_without_a_callback( + self): + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'old'}]) + lm.setup(updated_callback=None) + + recording = _RecordingSignal() + monkeypatch.setattr(model, 'updated', recording) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_TITLE, 'new') + + self.assertEqual(recording.calls, []) + + def test_set_value_before_setup_raises_attribute_error(self): + # CURRENT BEHAVIOR: __init__ never initialises _updated_callback -- + # only setup() does -- so set_value on a constructed-but-not-set-up + # model raises AttributeError instead of cleanly skipping the + # disconnect the way the `is not None` guard implies it would. + # Not reachable through any current caller: every real construction + # site (basejournalview._reset_model, called from listview.py's and + # gridview.py's _do_refresh) calls .setup() synchronously right + # after building the model and before it is ever wired to a view, + # so no caller can reach set_value() first. Recorded as a fragile + # invariant enforced only by call-site discipline, not the class. + monkeypatch = _MonkeyPatch(self) + lm, _rs = _make_model(monkeypatch, [{'uid': 'a', 'title': 'old'}]) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + with self.assertRaises(AttributeError): + lm.set_value(_iter_at(0), listmodel.ListModel.COLUMN_TITLE, 'new') + + +# --- do_get_value / set_value interaction: the same-index read cache --- + +class DoGetValueSetValueInteractionTest(unittest.TestCase): + + def test_do_get_value_after_set_value_serves_the_fresh_row( + self): + # set_value invalidates both row caches, so re-reading the edited + # index sees the edit at once instead of the pre-edit cached row. + monkeypatch = _MonkeyPatch(self) + stub_misc(monkeypatch) + lm, _rs = _make_model( + monkeypatch, + [{'uid': 'a', 'activity': 'x', 'title': 'old'}, + {'uid': 'b', 'activity': 'x', 'title': 'other'}]) + lm.setup(updated_callback=None) + monkeypatch.setattr(model, 'write', lambda *a, **kw: None) + column = listmodel.ListModel.COLUMN_TITLE + + self.assertEqual(lm.do_get_value(_iter_at(0), column), 'old') + lm.set_value(_iter_at(0), column, 'new') + self.assertEqual(lm.do_get_value(_iter_at(0), column), 'new') + + +if __name__ == '__main__': + unittest.main()