From 47b8aa87f90d9d39dbc71652061b8a3cf48a21c7 Mon Sep 17 00:00:00 2001 From: Sam Parkinson Date: Wed, 14 Nov 2018 21:53:51 +1100 Subject: [PATCH] Expand test suite for views (and refactor/fix bugs found) --- .gitignore | 2 + redditisgtk/Makefile.am | 1 + redditisgtk/comments.py | 341 +++++------------- redditisgtk/gtktestutil.py | 25 ++ redditisgtk/gtkutil.py | 2 +- redditisgtk/posttopbar.py | 227 ++++++++++++ redditisgtk/subentry.py | 3 + redditisgtk/test_comments.py | 3 +- redditisgtk/test_gtkutil.py | 20 +- redditisgtk/test_identitybutton.py | 66 ++++ redditisgtk/test_posttopbar.py | 90 +++++ redditisgtk/test_subentry.py | 116 ++++++ .../tests-data/posttopbar--comment.json | 1 + redditisgtk/tests-data/posttopbar--post.json | 2 + 14 files changed, 629 insertions(+), 270 deletions(-) create mode 100644 redditisgtk/posttopbar.py create mode 100644 redditisgtk/test_identitybutton.py create mode 100644 redditisgtk/test_posttopbar.py create mode 100644 redditisgtk/tests-data/posttopbar--comment.json create mode 100644 redditisgtk/tests-data/posttopbar--post.json diff --git a/.gitignore b/.gitignore index d3f238d..79cf989 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ Makefile.in .coverage .flatpak-builder build-dir +flatpak-repo +htmlcov diff --git a/redditisgtk/Makefile.am b/redditisgtk/Makefile.am index d4d6a03..f715014 100644 --- a/redditisgtk/Makefile.am +++ b/redditisgtk/Makefile.am @@ -17,6 +17,7 @@ app_PYTHON = \ mediapreview.py \ newmarkdown.py \ palettebutton.py \ + posttopbar.py \ readcontroller.py \ settings.py \ subentry.py \ diff --git a/redditisgtk/comments.py b/redditisgtk/comments.py index 3cf5c79..f660bc6 100644 --- a/redditisgtk/comments.py +++ b/redditisgtk/comments.py @@ -31,6 +31,7 @@ TimeButtonBehaviour, SubButtonBehaviour) from redditisgtk.gtkutil import process_shortcuts from redditisgtk import emptyview +from redditisgtk import posttopbar ENABLE_PROFILE = 'COMMENTS_PROFILE' in os.environ @@ -80,8 +81,9 @@ def __init__(self, api: RedditAPI, post=None, comments=None, permalink=None): def _init_post(self, post): self.got_post_data.emit(post) - self._top = _PostTopBar(self._api, post, self, hideable=False, - refreshable=True, show_subreddit=True) + self._top = posttopbar.PostTopBar( + self._api, post, self, hideable=False, + refreshable=True, show_subreddit=True) self._top.get_style_context().add_class('root-comments-bar') self._box.add(self._top) self._top.show() @@ -180,7 +182,7 @@ def reply_posted(self, new_id): def get_original_poster(self): return self._post['author'] - def _get_next_row(self, relative_to): + def get_next_row(self, relative_to): if relative_to == self._top: return self._load_full or self._comments.get_children()[0] if relative_to == self._load_full: @@ -189,9 +191,9 @@ def _get_next_row(self, relative_to): row = relative_to # 1st, try to see if I have a child to descend into if isinstance(row, CommentRow) and row.get_sub() is not None: - child = row.get_sub().get_children()[0] - if child.get_mapped(): - return child + children = row.get_sub().get_children() + if len(children) >= 1 and children[0].get_mapped(): + return children[0] # 2nd, try siblings and parents iteratively while isinstance(row, CommentRow): @@ -209,12 +211,12 @@ def _get_next_row(self, relative_to): def _get_last_child_of(self, row): if row.get_sub() is not None: - child = row.get_sub().get_children()[-1] - if child.get_mapped(): - return self._get_last_child_of(child) + children = row.get_sub().get_children() + if len(children) >= 1 and children[-1].get_mapped(): + return self._get_last_child_of(children[-1]) return row - def _get_prev_row(self, row): + def get_prev_row(self, row): if self._load_full is not None and row == self._load_full: return self._top @@ -255,15 +257,14 @@ def move(direction, jump): if 0 <= i + direction < len(kids): row = kids[i + direction] else: - f = self._get_next_row if direction > 0 else self._get_prev_row + f = self.get_next_row if direction > 0 else self.get_prev_row while True: row = f(self._selected) if row is None or row.get_mapped(): break if row is not None: - row.grab_focus() - self._selected = row + self.select_row(row) else: # We went too far! self.error_bell() @@ -292,6 +293,10 @@ def load_full(): } return process_shortcuts(shortcuts, event) + def select_row(self, row): + row.grab_focus() + self._selected = row + class _CommentsView(Gtk.ListBox): ''' @@ -332,6 +337,11 @@ def __draw_cb(self, widget, cr): print('[COMMENTS PROFILE] Done', time.time() - profile.start) def _add_comments(self, data): + ''' + Add comments from the given data list + + Returns the widget for the first comment added + ''' index = 0 def do_add_comment(): nonlocal index @@ -340,17 +350,25 @@ def do_add_comment(): return comment_data = data[index]['data'] - row = CommentRow(self._api, comment_data, self._depth, self._toplevel_cv) + if 'body' in comment_data: + row = NormalCommentRow(self._api, comment_data, self._depth, self._toplevel_cv) + else: + row = LoadMoreCommentsRow(self._api, comment_data, self._depth, self._toplevel_cv) + row.got_more_comments.connect(self.__got_more_comments_cb) row.recurse() - row.got_more_comments.connect(self.__got_more_comments_cb) self.insert(row, -1) row.show() + if self._first_row is None: + self._first_row = row + index += 1 if index < len(data): GLib.idle_add(do_add_comment) - do_add_comment() + return row + + return do_add_comment() def do_row_activated(self, row): if self._is_first: @@ -370,12 +388,18 @@ def do_row_activated(self, row): row.do_activated() - def __got_more_comments_cb(self, caller_button, more_comments): - caller_button.hide() - self.remove(caller_button) - caller_button.destroy() + def __got_more_comments_cb(self, caller_row, more_comments): + row = self._add_comments(more_comments) + if row is None: + # If no comments were added, just select the previous row + row = self._toplevel_cv.get_prev_row(caller_row) + + if row is not None: + self._toplevel_cv.select_row(row) - self._add_comments(more_comments) + caller_row.hide() + self.remove(caller_row) + caller_row.destroy() class LoadFullCommentsRow(Gtk.ListBoxRow): @@ -404,225 +428,28 @@ def __response_cb(self, ib, response): self._button.props.label = 'Loading...' -class _PostTopBar(Gtk.Bin): - - hide_toggled = GObject.Signal('hide-toggled', arg_types=[bool]) - - def __init__(self, api: RedditAPI, data, toplevel_cv, hideable=True, - refreshable=False, show_subreddit=False): - Gtk.Bin.__init__(self, can_focus=True) +class CommentRow(Gtk.ListBoxRow): + def __init__(self, data, depth): + Gtk.ListBoxRow.__init__(self, selectable=False) self.add_events(Gdk.EventMask.KEY_PRESS_MASK) - self._api = api - self.data = data - self._toplevel_cv = toplevel_cv - - self._b = Gtk.Builder.new_from_resource( - '/today/sam/reddit-is-gtk/post-top-bar.ui') - self.add(self._b.get_object('box')) - self.get_child().show() - - self.expand = self._b.get_object('expand') - self.expand.props.visible = hideable - self._b.get_object('refresh').props.visible = refreshable - - self._favorite = self._b.get_object('favorite') - self._favorite.props.visible = 'saved' in self.data - self._favorite.props.active = self.data.get('saved') - - self._name_button = self._b.get_object('name') - self._abb = AuthorButtonBehaviour( - self._name_button, self.data, - self._toplevel_cv.get_original_poster(), - show_flair=True) - - self._score_button = self._b.get_object('score') - self._score_button.props.visible = 'score' in data - if 'score' in data: - self._sbb = ScoreButtonBehaviour( - self._api, self._score_button, self.data) - - self._time_button = self._b.get_object('time') - self._tbb = TimeButtonBehaviour(self._time_button, self.data) - - self._reply_button = self._b.get_object('reply') - self._reply_pb = connect_palette( - self._reply_button, self._make_reply_palette, recycle_palette=True) - - self._sub_button = self._b.get_object('sub') - self._sub_button.props.visible = show_subreddit - if show_subreddit: - self._subbb = SubButtonBehaviour(self._sub_button, self.data) - - self._b.connect_signals(self) - - # We need to lazy allocate this list, otherwise we get bogus sizes - self._hideables = None - - def do_get_request_mode(self): - return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH - - def do_get_preferred_width(self): - minimum, natural = Gtk.Bin.do_get_preferred_width(self) - return 0, natural - - def do_get_preferred_height_for_width(self, width): - # FIXME: Worse for performance than the nested ListBoxes?? - if self._hideables is None: - self._hideables = [] - for child in self._b.get_object('box').get_children(): - if child.props.visible: - minimum, natural = child.get_preferred_width() - self._hideables.append((child, natural)) - - new_width = 0 - for b, w in self._hideables: - new_width += w - if new_width > width: - b.hide() - else: - b.show() - - minh, nath = Gtk.Bin.do_get_preferred_height(self) - return minh, nath - - def do_event(self, event): - def toggle(button): - button.props.active = not button.props.active - - def activate(button): - button.props.active = True - - shortcuts = { - 'u': (self._sbb.vote, [+1]), - 'd': (self._sbb.vote, [-1]), - 'n': (self._sbb.vote, [0]), - 'f': (toggle, [self._favorite]), - 't': (activate, [self._time_button]), - 'a': (self.get_toplevel().goto_sublist, - ['/u/{}'.format(self.data['author'])]), - 's': (self.get_toplevel().goto_sublist, - ['/r/{}'.format(self.data['subreddit'])]), - 'r': (self.show_reply, []), - 'space': (toggle, [self.expand]), - # The ListBoxRow usually eats these shortcuts, but we want - # the ListBox to handle them, so we need to pass it up - 'Up': (self._toplevel_cv.do_event, [event]), - 'Down': (self._toplevel_cv.do_event, [event]), - } - return process_shortcuts(shortcuts, event) - - def show_reply(self): - if self._reply_button.props.visible: - self._reply_button.props.active = True - else: - self._show_reply_modal() - def refresh_clicked_cb(self, button): - self._toplevel_cv.refresh() - - def read_toggled_cb(self, toggle): - if toggle.props.active: - self._api.read_message(self.data['name']) - self._read.get_style_context().remove_class('unread') - self._read.props.active = True - self._read.props.sensitive = False - self._read.props.label = 'Read' - - def hide_toggled_cb(self, toggle): - self.hide_toggled.emit(not toggle.props.active) - - def favorite_toggled_cb(self, button): - self._api.set_saved(self.data['name'], button.props.active, - None) - - def _make_reply_palette(self): - popover = Gtk.Popover() - contents = _ReplyPopoverContents(self._api, self.data) - contents.posted.connect(self.__reply_posted_cb) - popover.add(contents) - return popover - - def _show_reply_modal(self): - dialog = Gtk.Dialog(use_header_bar=True) - contents = _ReplyPopoverContents(self._api, self.data, - header_bar=dialog.get_header_bar()) - dialog.get_content_area().add(contents) - contents.posted.connect(self.__reply_posted_cb) - - dialog.props.transient_for = self.get_toplevel() - dialog.show() - - def __reply_posted_cb(self, caller, new_id): - self._toplevel_cv.reply_posted(new_id) - - -class _ReplyPopoverContents(Gtk.Box): - - posted = GObject.Signal('posted', arg_types=[str]) - - def __init__(self, api: RedditAPI, data, header_bar=None, **kwargs): - Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) self.data = data - self._api = api - - sw = Gtk.ScrolledWindow() - sw.set_size_request(500, 300) - self.add(sw) - self._textview = Gtk.TextView() - self._textview.props.wrap_mode = Gtk.WrapMode.WORD - self._textview.set_size_request(500, 300) - self._textview.connect('event', self.__event_cb) - sw.add(self._textview) - - self._done = Gtk.Button(label='Post Reply') - self._done.connect('clicked', self.__done_clicked_cb) - if header_bar is not None: - header_bar.pack_end(self._done) - self._done.get_style_context().add_class('suggested-action') - self._done.show() - else: - self.add(self._done) - - self.show_all() - - def __event_cb(self, textview, event): - shortcuts = { - 'Return': (self.__done_clicked_cb, [None]) - } - return process_shortcuts(shortcuts, event) - - def __done_clicked_cb(self, button): - self._done.props.label = 'Sending...' - self._done.props.sensitive = False - b = self._textview.props.buffer - text = b.get_text(b.get_start_iter(), b.get_end_iter(), False) - self._api.reply(self.data['name'], text, self.__reply_done_cb) - - def __reply_done_cb(self, data): - new_id = data['json']['data']['things'][0]['data']['id'] - self.posted.emit(new_id) + self.depth = depth - parent = self.get_parent() - parent.hide() - parent.destroy() - self.destroy() + def get_sub(self): + return None -class CommentRow(Gtk.ListBoxRow): +class NormalCommentRow(CommentRow): refresh = GObject.Signal('refresh') - got_more_comments = GObject.Signal('got-more-comments', arg_types=[object]) def __init__(self, api: RedditAPI, data, depth, toplevel_cv): - Gtk.ListBoxRow.__init__(self, selectable=False) - self.add_events(Gdk.EventMask.KEY_PRESS_MASK) + super().__init__(data, depth) self._api = api - self.data = data - self.depth = depth self._toplevel_cv = toplevel_cv self._top = None self._sub = None - self._more_button = None self._box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.add(self._box) @@ -631,36 +458,13 @@ def __init__(self, api: RedditAPI, data, depth, toplevel_cv): def get_sub(self): return self._sub - def recurse(self): - if 'body' in self.data: - self._show_normal_comment() - else: - self._more_button = Gtk.Button.new_with_label( - 'Show {} more comments...'.format(self.data['count'])) - self._more_button.connect('clicked', self.__load_more_cb) - self._more_button.get_style_context().add_class('load-more') - self._box.add(self._more_button) - self._more_button.show() - - def do_focus_in_event(self, event): - if self._more_button is not None: - self._more_button.grab_focus() - def do_event(self, event): if self._top is not None: return self._top.do_event(event) - def __load_more_cb(self, button): - button.props.sensitive = False - self._api.load_more( - self._toplevel_cv.get_link_name(), - self.data, self.__loaded_more_cb) - - def __loaded_more_cb(self, comments): - self.got_more_comments.emit(comments) - - def _show_normal_comment(self): - self._top = _PostTopBar(self._api, self.data, self._toplevel_cv) + def recurse(self): + self._top = posttopbar.PostTopBar( + self._api, self.data, self._toplevel_cv) self._top.hide_toggled.connect(self.__hide_toggled_cb) self._box.add(self._top) self._top.show() @@ -698,3 +502,36 @@ def do_activated(self): rc = not self._revealer.props.reveal_child self._revealer.props.reveal_child = rc self._top.expand.props.active = not rc + + +class LoadMoreCommentsRow(CommentRow): + + got_more_comments = GObject.Signal('got-more-comments', arg_types=[object]) + + def __init__(self, api: RedditAPI, data, depth, toplevel_cv): + super().__init__(data, depth) + + self._api = api + self._toplevel_cv = toplevel_cv + self._more_button = None + + def recurse(self): + self._more_button = Gtk.Button.new_with_label( + 'Show {} more comments...'.format(self.data['count'])) + self._more_button.connect('clicked', self.__load_more_cb) + self._more_button.get_style_context().add_class('load-more') + self.add(self._more_button) + self._more_button.show() + + def do_focus_in_event(self, event): + if self._more_button is not None: + self._more_button.grab_focus() + + def __load_more_cb(self, button): + button.props.sensitive = False + self._api.load_more( + self._toplevel_cv.get_link_name(), + self.data, self.__loaded_more_cb) + + def __loaded_more_cb(self, comments): + self.got_more_comments.emit(comments) diff --git a/redditisgtk/gtktestutil.py b/redditisgtk/gtktestutil.py index ae1dbf7..2611ba7 100644 --- a/redditisgtk/gtktestutil.py +++ b/redditisgtk/gtktestutil.py @@ -1,8 +1,10 @@ import time import typing import functools +from unittest.mock import MagicMock from gi.repository import Gtk +from gi.repository import Gdk from gi.repository import GLib from gi.repository import Gio @@ -45,6 +47,12 @@ def _iter_all_widgets(root: Gtk.Widget): yield from _iter_all_widgets(child) +def get_focused(root: Gtk.Widget) -> Gtk.Widget: + for widget in _iter_all_widgets(root): + if widget.is_focus(): + return widget + + def get_label_for_widget(widget: Gtk.Widget): my_label = None if hasattr(widget, 'get_label'): @@ -56,6 +64,9 @@ def get_label_for_widget(widget: Gtk.Widget): if hasattr(child, 'get_label'): my_label = child.get_label() + if not my_label and hasattr(widget, 'get_text'): + my_label = widget.get_text() + return my_label @@ -118,3 +129,17 @@ def wait_for(cond: typing.Callable[[], bool], timeout: float= 5): raise AssertionError('Timeout expired') Gtk.main_iteration_do(False) + + +def fake_event(keyval, event_type=Gdk.EventType.KEY_PRESS, ctrl=False): + if isinstance(keyval, str): + keyval = ord(keyval) + + ev = MagicMock() + ev.type = event_type + ev.keyval = keyval + if ctrl: + ev.state = Gdk.ModifierType.CONTROL_MASK + else: + ev.state = 0 + return ev diff --git a/redditisgtk/gtkutil.py b/redditisgtk/gtkutil.py index 61f61ac..4256d87 100644 --- a/redditisgtk/gtkutil.py +++ b/redditisgtk/gtkutil.py @@ -39,6 +39,6 @@ def process_shortcuts(shortcuts, event: Gdk.Event): try: func(*args) except Exception as e: - print(e) + raise return False return True diff --git a/redditisgtk/posttopbar.py b/redditisgtk/posttopbar.py new file mode 100644 index 0000000..f164797 --- /dev/null +++ b/redditisgtk/posttopbar.py @@ -0,0 +1,227 @@ +# Copyright 2018 Sam Parkinson +# +# This file is part of Something for Reddit. +# +# Something for Reddit 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. +# +# Something for Reddit 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 Something for Reddit. If not, see . + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Gdk + +from redditisgtk.palettebutton import connect_palette +from redditisgtk.api import RedditAPI +from redditisgtk.buttons import (ScoreButtonBehaviour, AuthorButtonBehaviour, + TimeButtonBehaviour, SubButtonBehaviour) +from redditisgtk.gtkutil import process_shortcuts + + +class PostTopBar(Gtk.Bin): + + hide_toggled = GObject.Signal('hide-toggled', arg_types=[bool]) + + def __init__(self, + api: RedditAPI, + data: dict, + toplevel_cv, + hideable=True, + refreshable=False, + show_subreddit=False): + Gtk.Bin.__init__(self, can_focus=True) + self.add_events(Gdk.EventMask.KEY_PRESS_MASK) + self._api = api + self.data = data + self._toplevel_cv = toplevel_cv + + self._b = Gtk.Builder.new_from_resource( + '/today/sam/reddit-is-gtk/post-top-bar.ui') + self.add(self._b.get_object('box')) + self.get_child().show() + + self.expand = self._b.get_object('expand') + self.expand.props.visible = hideable + self._b.get_object('refresh').props.visible = refreshable + + self._favorite = self._b.get_object('favorite') + self._favorite.props.visible = 'saved' in self.data + self._favorite.props.active = self.data.get('saved') + + self._name_button = self._b.get_object('name') + self._abb = AuthorButtonBehaviour( + self._name_button, self.data, + self._toplevel_cv.get_original_poster(), + show_flair=True) + + self._score_button = self._b.get_object('score') + self._score_button.props.visible = 'score' in data + if 'score' in data: + self._sbb = ScoreButtonBehaviour( + self._api, self._score_button, self.data) + + self._time_button = self._b.get_object('time') + self._tbb = TimeButtonBehaviour(self._time_button, self.data) + + self._reply_button = self._b.get_object('reply') + self._reply_pb = connect_palette( + self._reply_button, self._make_reply_palette, recycle_palette=True) + + self._sub_button = self._b.get_object('sub') + self._sub_button.props.visible = show_subreddit + if show_subreddit: + self._subbb = SubButtonBehaviour(self._sub_button, self.data) + + self._b.connect_signals(self) + + # We need to lazy allocate this list, otherwise we get bogus sizes + self._hideables = None + + def do_get_request_mode(self): + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH + + def do_get_preferred_width(self): + minimum, natural = Gtk.Bin.do_get_preferred_width(self) + return 0, natural + + def do_get_preferred_height_for_width(self, width): + # FIXME: Worse for performance than the nested ListBoxes?? + if self._hideables is None: + self._hideables = [] + for child in self._b.get_object('box').get_children(): + if child.props.visible: + minimum, natural = child.get_preferred_width() + self._hideables.append((child, natural)) + + new_width = 0 + for b, w in self._hideables: + new_width += w + if new_width > width: + b.hide() + else: + b.show() + + minh, nath = Gtk.Bin.do_get_preferred_height(self) + return minh, nath + + def do_event(self, event): + def toggle(button): + button.props.active = not button.props.active + + def activate(button): + button.props.active = True + + shortcuts = { + 'u': (self._sbb.vote, [+1]), + 'd': (self._sbb.vote, [-1]), + 'n': (self._sbb.vote, [0]), + 'f': (toggle, [self._favorite]), + 't': (activate, [self._time_button]), + 'a': (self.get_toplevel().goto_sublist, + ['/u/{}'.format(self.data['author'])]), + 's': (self.get_toplevel().goto_sublist, + ['/r/{}'.format(self.data['subreddit'])]), + 'r': (self.show_reply, []), + 'space': (toggle, [self.expand]), + # The ListBoxRow usually eats these shortcuts, but we want + # the ListBox to handle them, so we need to pass it up + 'Up': (self._toplevel_cv.do_event, [event]), + 'Down': (self._toplevel_cv.do_event, [event]), + } + return process_shortcuts(shortcuts, event) + + def show_reply(self): + if self._reply_button.props.visible: + self._reply_button.props.active = True + else: + self._show_reply_modal() + + def refresh_clicked_cb(self, button): + self._toplevel_cv.refresh() + + def hide_toggled_cb(self, toggle): + self.hide_toggled.emit(not toggle.props.active) + + def favorite_toggled_cb(self, button): + self._api.set_saved(self.data['name'], button.props.active, + None) + + def _make_reply_palette(self): + popover = Gtk.Popover() + contents = _ReplyPopoverContents(self._api, self.data) + contents.posted.connect(self.__reply_posted_cb) + popover.add(contents) + return popover + + def _show_reply_modal(self): + dialog = Gtk.Dialog(use_header_bar=True) + contents = _ReplyPopoverContents(self._api, self.data, + header_bar=dialog.get_header_bar()) + dialog.get_content_area().add(contents) + contents.posted.connect(self.__reply_posted_cb) + + dialog.props.transient_for = self.get_toplevel() + dialog.show() + + def __reply_posted_cb(self, caller, new_id): + self._toplevel_cv.reply_posted(new_id) + + +class _ReplyPopoverContents(Gtk.Box): + + posted = GObject.Signal('posted', arg_types=[str]) + + def __init__(self, api: RedditAPI, data, header_bar=None, **kwargs): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + self.data = data + self._api = api + + sw = Gtk.ScrolledWindow() + sw.set_size_request(500, 300) + self.add(sw) + self._textview = Gtk.TextView() + self._textview.props.wrap_mode = Gtk.WrapMode.WORD + self._textview.set_size_request(500, 300) + self._textview.connect('event', self.__event_cb) + sw.add(self._textview) + + self._done = Gtk.Button(label='Post Reply') + self._done.connect('clicked', self.__done_clicked_cb) + if header_bar is not None: + header_bar.pack_end(self._done) + self._done.get_style_context().add_class('suggested-action') + self._done.show() + else: + self.add(self._done) + + self.show_all() + + def __event_cb(self, textview, event): + shortcuts = { + 'Return': (self.__done_clicked_cb, [None]) + } + return process_shortcuts(shortcuts, event) + + def __done_clicked_cb(self, button): + self._done.props.label = 'Sending...' + self._done.props.sensitive = False + b = self._textview.props.buffer + text = b.get_text(b.get_start_iter(), b.get_end_iter(), False) + self._api.reply(self.data['name'], text, self.__reply_done_cb) + + def __reply_done_cb(self, data): + new_id = data['json']['data']['things'][0]['data']['id'] + self.posted.emit(new_id) + + parent = self.get_parent() + parent.hide() + parent.destroy() + self.destroy() diff --git a/redditisgtk/subentry.py b/redditisgtk/subentry.py index e3c35a1..8ba82a3 100644 --- a/redditisgtk/subentry.py +++ b/redditisgtk/subentry.py @@ -34,6 +34,9 @@ def clean_sub(sub): And normalize /u/ -> /user/ ''' + if sub.startswith('http://') or sub.startswith('https://'): + return sub + if sub.endswith('/'): sub = sub[:-1] if not sub.startswith('/'): diff --git a/redditisgtk/test_comments.py b/redditisgtk/test_comments.py index 46bb13b..2d540aa 100644 --- a/redditisgtk/test_comments.py +++ b/redditisgtk/test_comments.py @@ -4,7 +4,8 @@ from gi.repository import Gtk from redditisgtk import comments -from redditisgtk.gtktestutil import with_test_mainloop, find_widget +from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, + get_focused, fake_event) PERMALINK = '/r/MaliciousCompliance/comments/9vzevr/you_need_a_doctors_note_you_got_it/' diff --git a/redditisgtk/test_gtkutil.py b/redditisgtk/test_gtkutil.py index e718f09..36c78c4 100644 --- a/redditisgtk/test_gtkutil.py +++ b/redditisgtk/test_gtkutil.py @@ -1,19 +1,7 @@ from unittest.mock import MagicMock -from gi.repository import Gdk - from redditisgtk import gtkutil - - -def fake_event(keyval, event_type=Gdk.EventType.KEY_PRESS, ctrl=False): - ev = MagicMock() - ev.type = event_type - ev.keyval = keyval - if ctrl: - ev.state = Gdk.ModifierType.CONTROL_MASK - else: - ev.state = 0 - return ev +from redditisgtk.gtktestutil import fake_event def test_process_shortcuts(): @@ -25,7 +13,7 @@ def test_process_shortcuts(): 'k': (k_cb, ['k']), } - gtkutil.process_shortcuts(shortcuts, fake_event('', event_type=None)) + gtkutil.process_shortcuts(shortcuts, fake_event('a', event_type=None)) assert not up_cb.called assert not k_cb.called @@ -33,9 +21,9 @@ def test_process_shortcuts(): assert up_cb.called assert up_cb.call_args[0] == ('up',) - gtkutil.process_shortcuts(shortcuts, fake_event(107)) + gtkutil.process_shortcuts(shortcuts, fake_event('k')) assert not k_cb.called - gtkutil.process_shortcuts(shortcuts, fake_event(107, ctrl=True)) + gtkutil.process_shortcuts(shortcuts, fake_event('k', ctrl=True)) assert k_cb.called assert k_cb.call_args[0] == ('k',) diff --git a/redditisgtk/test_identitybutton.py b/redditisgtk/test_identitybutton.py new file mode 100644 index 0000000..126affa --- /dev/null +++ b/redditisgtk/test_identitybutton.py @@ -0,0 +1,66 @@ +from unittest.mock import MagicMock + +from gi.repository import Gtk + +from redditisgtk import identitybutton +from redditisgtk.gtktestutil import with_test_mainloop, find_widget, wait_for + +@with_test_mainloop +def test_button_shows_name(): + ic = MagicMock() + ic.active_token.user_name = 'UN1' + + btn = identitybutton.IdentityButton(ic) + assert find_widget(btn, label='UN1', kind=Gtk.Button) + + (cb,), _ = ic.token_changed.connect.call_args + ic.active_token.user_name = 'UN2' + cb(ic) + assert find_widget(btn, label='UN2', kind=Gtk.Button) + + +@with_test_mainloop +def test_popover_lists_accounts(): + ic = MagicMock() + ic.all_tokens = [ + (1, MagicMock(user_name='user name 1')), + (2, MagicMock(user_name='user name 2'))] + + popover = identitybutton._IdentityPopover(ic) + assert find_widget(popover, label='Anonymous') + assert find_widget(popover, label='user name 1') + assert find_widget(popover, label='user name 2') + + ic.all_tokens = [(1, MagicMock(user_name='user name 1 new'))] + (cb,), _ = ic.token_changed.connect.call_args + cb(ic) + assert find_widget(popover, label='Anonymous') + assert find_widget(popover, label='user name 1 new') + assert find_widget(popover, label='user name 2', many=True) == [] + assert find_widget(popover, label='user name 1', many=True) == [] + +@with_test_mainloop +def test_popover_selects_row(): + ic = MagicMock() + ic.all_tokens = [ + (1, MagicMock(user_name='user name 1')), + (2, MagicMock(user_name='user name 2'))] + ic.active_token = ic.all_tokens[0][1] + + popover = identitybutton._IdentityPopover(ic) + + def get_row(text: str): + label = find_widget(popover, label=text) + while not isinstance(label, Gtk.ListBoxRow): + assert label + label = label.get_parent() + return label + + row1 = get_row('user name 1') + row2 = get_row('user name 2') + listbox = find_widget(popover, kind=Gtk.ListBox) + + assert listbox.get_selected_rows() == [row1] + listbox.emit('row-selected', row2) + wait_for(lambda: ic.switch_account.called) + assert ic.switch_account.call_args[0][0] == 2 diff --git a/redditisgtk/test_posttopbar.py b/redditisgtk/test_posttopbar.py new file mode 100644 index 0000000..4ecda3d --- /dev/null +++ b/redditisgtk/test_posttopbar.py @@ -0,0 +1,90 @@ +import json +from unittest.mock import MagicMock, patch + +from gi.repository import Gtk + +from redditisgtk import posttopbar +from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, + fake_event) + + +@with_test_mainloop +def test_for_comment(datadir): + api = MagicMock() + toplevel_cv = MagicMock() + + with open(datadir / 'posttopbar--comment.json') as f: + data = json.load(f) + + bar = posttopbar.PostTopBar(api, data, toplevel_cv) + # author + assert find_widget(bar, label='andnbspsc', kind=Gtk.Button) + # score + assert find_widget(bar, label='score hidden', kind=Gtk.Button) + # no subreddit + assert not find_widget(bar, label='linux', many=True) + + +@with_test_mainloop +def test_for_post(datadir): + api = MagicMock() + toplevel_cv = MagicMock() + + with open(datadir / 'posttopbar--post.json') as f: + data = json.load(f) + + bar = posttopbar.PostTopBar(api, data, toplevel_cv, show_subreddit=True) + # author + assert find_widget(bar, label='sandragen', kind=Gtk.Button) + # score + assert find_widget(bar, label='score hidden', kind=Gtk.Button) + # subreddit + assert find_widget(bar, label='linux', many=True) + + +@with_test_mainloop +def test_vote_key(datadir): + api = MagicMock() + toplevel_cv = MagicMock() + with open(datadir / 'posttopbar--comment.json') as f: + data = json.load(f) + + bar = posttopbar.PostTopBar(api, data, toplevel_cv) + bar.get_toplevel = lambda: api + bar.do_event(fake_event('u')) + assert api.vote.call_args[0] == ('t1_e9nhj7n', +1) + bar.do_event(fake_event('d')) + assert api.vote.call_args[0] == ('t1_e9nhj7n', -1) + bar.do_event(fake_event('n')) + assert api.vote.call_args[0] == ('t1_e9nhj7n', 0) + + +@with_test_mainloop +def test_reply_palette(datadir): + api = MagicMock() + toplevel_cv = MagicMock() + with open(datadir / 'posttopbar--comment.json') as f: + data = json.load(f) + + bar = posttopbar.PostTopBar(api, data, toplevel_cv) + bar.get_toplevel = lambda: api + poproot = Gtk.Popover() + with patch('gi.repository.Gtk.Popover') as Popover: + Popover.return_value = poproot + bar.do_event(fake_event('r')) + + wait_for(lambda: Popover.called) + + assert poproot.props.visible + tv = find_widget(poproot, kind=Gtk.TextView) + tv.props.buffer.set_text('hello') + + btn = find_widget(poproot, kind=Gtk.Button, label='Post Reply') + btn.emit('clicked') + wait_for(lambda: btn.props.sensitive is False) + (name, text, cb), _ = api.reply.call_args + assert name == 't1_e9nhj7n' + assert text == 'hello' + + cb({'json': {'data': {'things': [{'data': {'id': 'MYID'}}]}}}) + assert poproot.props.visible == False diff --git a/redditisgtk/test_subentry.py b/redditisgtk/test_subentry.py index a3080e3..ebdb0af 100644 --- a/redditisgtk/test_subentry.py +++ b/redditisgtk/test_subentry.py @@ -1,4 +1,10 @@ +from unittest.mock import MagicMock + +from gi.repository import Gtk + from redditisgtk import subentry +from redditisgtk.gtktestutil import (with_test_mainloop, find_widget, wait_for, + fake_event) def test_clean_sub(): @@ -7,15 +13,125 @@ def test_clean_sub(): assert subentry.clean_sub('/u/sam') == '/user/sam' +def test_clean_sub_passes_through_uris(): + assert subentry.clean_sub('https://') == 'https://' + assert subentry.clean_sub('http://reddit.com') == 'http://reddit.com' + + def test_format_sub_for_api_frontpage(): assert subentry.format_sub_for_api('') == '/hot' assert subentry.format_sub_for_api('/') == '/hot' assert subentry.format_sub_for_api('/top') == '/top' + def test_format_sub_for_api_subreddit(): assert subentry.format_sub_for_api('r/linux') == '/r/linux/hot' assert subentry.format_sub_for_api('/r/l/top?t=all') == '/r/l/top?t=all' + def test_format_sub_for_api_user(): assert subentry.format_sub_for_api('/u/sam') == '/user/sam/overview' assert subentry.format_sub_for_api('/u/sam/up') == '/user/sam/up' + + +@with_test_mainloop +def test_subentry_create(): + api = MagicMock() + api.user_name = 'username' + root = subentry.SubEntry(api, text='/r/linux') + + assert find_widget(root, label='/r/linux') + + +@with_test_mainloop +def test_subentry_palette_activate(): + api = MagicMock() + api.user_name = 'username' + root = subentry.SubEntry(api) + root.activate = MagicMock() + + down_button = find_widget(root, kind=Gtk.Button) + down_button.emit('clicked') + poproot = root._palette # err IDK about this + wait_for(lambda: poproot.props.visible) + + btn = find_widget( + poproot, label='/user/username/submitted', kind=Gtk.Button) + btn.emit('clicked') + wait_for(lambda: root.activate.emit.called) + assert root.activate.emit.call_args[0][0] == '/user/username/submitted' + + +@with_test_mainloop +def test_subentry_palette_subreddits(): + api = MagicMock() + api.user_name = 'username' + api.user_subs = ['/r/linux'] + root = subentry.SubEntry(api) + + down_button = find_widget(root, kind=Gtk.Button) + down_button.emit('clicked') + poproot = root._palette # err IDK about this + wait_for(lambda: poproot.props.visible) + + assert find_widget(poproot, label='/r/linux', many=True) + assert not find_widget(poproot, label='/r/gnu', many=True) + + api.user_subs = ['/r/gnu'] + (cb,), _ = api.subs_changed.connect.call_args + cb(api) + assert not find_widget(poproot, label='/r/linux', many=True) + assert find_widget(poproot, label='/r/gnu', many=True) + + +@with_test_mainloop +def test_subentry_open_uri(): + api = MagicMock() + api.user_name = 'username' + root = subentry.SubEntry(api) + toplevel = MagicMock() + + entry = find_widget(root, kind=Gtk.Entry) + # err IDK about this + entry.is_focus = lambda: True + entry.props.text = 'https://reddit.com/r/yes' + + poproot = root._palette # err IDK about this + poproot.get_toplevel = lambda: toplevel + wait_for(lambda: poproot.props.visible) + + btn = find_widget(poproot, label='Open this reddit.com URI', + kind=Gtk.Button) + btn.emit('clicked') + wait_for(lambda: toplevel.goto_reddit_uri.called) + toplevel.goto_reddit_uri.assert_called_once_with( + 'https://reddit.com/r/yes') + + +@with_test_mainloop +def test_subentry_palette_subreddits_filter(): + api = MagicMock() + api.user_name = 'username' + api.user_subs = ['/r/linux', '/r/gnu'] + root = subentry.SubEntry(api) + poproot = root._palette # err IDK about this + + entry = find_widget(root, kind=Gtk.Entry) + entry.props.text = '/r/l' + + + # When using the button, all should be visible + down_button = find_widget(root, kind=Gtk.Button) + down_button.emit('clicked') + wait_for(lambda: poproot.props.visible) + + assert find_widget(poproot, label='/r/linux', many=True) + assert find_widget(poproot, label='/r/gnu', many=True) + + # err IDK about this + entry.is_focus = lambda: True + entry.props.text = '/r/li' + wait_for(lambda: poproot.props.visible) + + assert find_widget(poproot, label='/r/linux', many=True) + assert not find_widget(poproot, label='/r/gnu', many=True) diff --git a/redditisgtk/tests-data/posttopbar--comment.json b/redditisgtk/tests-data/posttopbar--comment.json new file mode 100644 index 0000000..b0040ec --- /dev/null +++ b/redditisgtk/tests-data/posttopbar--comment.json @@ -0,0 +1 @@ +{"subreddit_id": "t5_2qh1a", "approved_at_utc": null, "ups": 1, "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "removal_reason": null, "link_id": "t3_9wuoss", "author_flair_template_id": null, "likes": null, "no_follow": true, "replies": "", "user_reports": [], "saved": false, "id": "e9nhj7n", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "report_reasons": null, "author": "andnbspsc", "can_mod_post": false, "send_replies": true, "parent_id": "t1_e9nfjwa", "score": 1, "author_fullname": "t2_zlqp1", "approved_by": null, "downs": 0, "body": "Oh woops ok bye!", "edited": false, "author_flair_css_class": null, "is_submitter": true, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "collapsed_reason": null, "body_html": "

Oh woops ok bye!

\n
", "stickied": false, "subreddit_type": "public", "can_gild": true, "gildings": {"gid_1": 0, "gid_2": 0, "gid_3": 0}, "author_flair_text_color": null, "score_hidden": true, "permalink": "/r/linux/comments/9wuoss/keyboard_shortcuts_between_terminal_and_other_gui/e9nhj7n/", "num_reports": null, "name": "t1_e9nhj7n", "created": 1542185030.0, "subreddit": "linux", "author_flair_text": null, "created_utc": 1542156230.0, "subreddit_name_prefixed": "r/linux", "controversiality": 0, "depth": 1, "author_flair_background_color": null, "mod_reports": [], "mod_note": null, "distinguished": null} diff --git a/redditisgtk/tests-data/posttopbar--post.json b/redditisgtk/tests-data/posttopbar--post.json new file mode 100644 index 0000000..88a3428 --- /dev/null +++ b/redditisgtk/tests-data/posttopbar--post.json @@ -0,0 +1,2 @@ +{"subreddit_id": "t5_2qh1a", "approved_at_utc": null, "ups": 1, "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "removal_reason": null, "link_id": "t3_9wuoss", "author_flair_template_id": null, "likes": null, "no_follow": true, "replies": {"kind": "Listing", "data": {"modhash": null, "dist": null, "children": [{"kind": "t1", "data": {"subreddit_id": "t5_2qh1a", "approved_at_utc": null, "ups": 1, "mod_reason_by": null, "banned_by": null, "author_flair_type": "text", "removal_reason": null, "link_id": "t3_9wuoss", "author_flair_template_id": null, "likes": null, "no_follow": true, "replies": "", "user_reports": [], "saved": false, "id": "e9nhj7n", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "report_reasons": null, "author": "andnbspsc", "can_mod_post": false, "send_replies": true, "parent_id": "t1_e9nfjwa", "score": 1, "author_fullname": "t2_zlqp1", "approved_by": null, "downs": 0, "body": "Oh woops ok bye!", "edited": false, "author_flair_css_class": null, "is_submitter": true, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "collapsed_reason": null, "body_html": "

Oh woops ok bye!

\n
", "stickied": false, "subreddit_type": "public", "can_gild": true, "gildings": {"gid_1": 0, "gid_2": 0, "gid_3": 0}, "author_flair_text_color": null, "score_hidden": true, "permalink": "/r/linux/comments/9wuoss/keyboard_shortcuts_between_terminal_and_other_gui/e9nhj7n/", "num_reports": null, "name": "t1_e9nhj7n", "created": 1542185030.0, "subreddit": "linux", "author_flair_text": null, "created_utc": 1542156230.0, "subreddit_name_prefixed": "r/linux", "controversiality": 0, "depth": 1, "author_flair_background_color": null, "mod_reports": [], "mod_note": null, "distinguished": null}}], "after": null, "before": null}}, "user_reports": [], "saved": false, "id": "e9nfjwa", "banned_at_utc": null, "mod_reason_title": null, "gilded": 0, "archived": false, "report_reasons": null, "author": "sandragen", "can_mod_post": false, "send_replies": true, "parent_id": "t3_9wuoss", "score": 1, "author_fullname": "t2_hwtp5", "approved_by": null, "downs": 0, "body": "Rule 1", "edited": false, "author_flair_css_class": null, "is_submitter": false, "collapsed": false, "author_flair_richtext": [], "author_patreon_flair": false, "collapsed_reason": null, "body_html": "

Rule 1

\n
", "stickied": false, "subreddit_type": "public", "can_gild": true, "gildings": {"gid_1": 0, "gid_2": 0, "gid_3": 0}, "author_flair_text_color": null, "score_hidden": true, "permalink": "/r/linux/comments/9wuoss/keyboard_shortcuts_between_terminal_and_other_gui/e9nfjwa/", "num_reports": null, "name": "t1_e9nfjwa", "created": 1542183175.0, "subreddit": "linux", "author_flair_text": null, "created_utc": 1542154375.0, "subreddit_name_prefixed": "r/linux", "controversiality": 0, "depth": 0, "author_flair_background_color": null, "mod_reports": [], "mod_note": null, "distinguished": null} +