Repository: biolab/orange3-text

Issue:
Word Cloud: don't update on resize
<!--
This is an issue template. Please fill in the relevant details in the
sections below.
-->

##### Text version
<!-- From menu _Options→Add-ons→Orange3-Text_ or code `orangecontrib.text.version.full_version` -->
0.5.2

##### Orange version
<!-- From menu _Help→About→Version_ or code `Orange.version.full_version` -->
3.17.dev

##### Expected behavior
Word Cloud stays the same for the same domain.


##### Actual behavior
WC constantly changes. WC should not update on resize. It should also show constant colours for users.


##### Steps to reproduce the behavior



##### Additional info (worksheets, data, screenshots, ...)





Discussion:
This problem is due to drawing the words with a third party javascript library. To fix this and other related problems with word cloud a reimplementation with qt is necessary. As this isn't planned for the near feuter, i will close this issue. 

Resolving patch:
diff --git a/orangecontrib/text/topics/topics.py b/orangecontrib/text/topics/topics.py
--- a/orangecontrib/text/topics/topics.py
+++ b/orangecontrib/text/topics/topics.py
@@ -103,6 +103,9 @@ def get_topics_table_by_id(self, topic_id):
                              metas=data)
         t.W = data[:, 1]
         t.name = 'Topic {}'.format(topic_id + 1)
+
+        # needed for coloring in word cloud
+        t.attributes["topic-method-name"] = self.model.__class__.__name__
         return t
 
     def get_all_topics_table(self):
diff --git a/orangecontrib/text/widgets/owwordcloud.py b/orangecontrib/text/widgets/owwordcloud.py
--- a/orangecontrib/text/widgets/owwordcloud.py
+++ b/orangecontrib/text/widgets/owwordcloud.py
@@ -1,21 +1,30 @@
 # coding: utf-8
 from collections import Counter
+from itertools import cycle
 from math import pi as PI
-from operator import itemgetter
+from typing import Dict, List, Optional
 
 import numpy as np
-from AnyQt.QtCore import Qt, QItemSelection, QItemSelectionModel, pyqtSlot, \
-    QObject, QSortFilterProxyModel
-from AnyQt.QtWidgets import QApplication
+from AnyQt import QtCore
+from AnyQt.QtCore import (QItemSelection, QItemSelectionModel, QObject, QSize,
+                          QSortFilterProxyModel, Qt, pyqtSlot)
 
-from Orange.data import StringVariable, ContinuousVariable, Domain, Table
+from Orange.data import ContinuousVariable, Domain, StringVariable, Table
 from Orange.data.util import scale
-from Orange.widgets import widget, gui, settings
+from Orange.widgets import gui, settings, widget
 from Orange.widgets.utils.itemmodels import PyTableModel
 from Orange.widgets.widget import Input, Output
 from orangecontrib.text.corpus import Corpus
 from orangecontrib.text.topics import Topic
 
+COLORS = ["#da1", "#629", "#787"]
+GRAY_COLORS = ["#000", "#444", "#777", "#aaa"]
+TOPIC_COLORS = ["#ff6600", "#00cc00"]  # [negative topic, positive topic]
+GRAY_TOPIC_COLORS = ["#000", "#aaa"]  # [negative topic, positive topic]
+TILT_VALUES = ("no", "30°", "45°", "60°")
+
+N_BEST_PLOTTED = 200
+
 
 class OWWordCloud(widget.OWWidget):
     name = "Word Cloud"
@@ -31,7 +40,7 @@ class Outputs:
         selected_words = Output("Selected Words", Topic, dynamic=False)
         word_counts = Output("Word Counts", Table)
 
-    graph_name = 'webview'
+    graph_name = "webview"
 
     selected_words = settings.Setting(set(), schema_only=True)
 
@@ -39,7 +48,9 @@ class Outputs:
     words_tilt = settings.Setting(0)
 
     class Warning(widget.OWWidget.Warning):
-        topic_precedence = widget.Msg('Input signal Topic takes priority over Corpus')
+        topic_precedence = widget.Msg(
+            "Input signal Topic takes priority over Corpus"
+        )
 
     class Info(widget.OWWidget.Information):
         bow_weights = widget.Msg("Showing bag of words weights.")
@@ -47,24 +58,30 @@ class Info(widget.OWWidget.Information):
     def __init__(self):
         super().__init__()
         self.n_topic_words = 0
-        self.documents_info_str = ''
+        self.documents_info_str = ""
         self.webview = None
         self.topic = None
         self.corpus = None
         self.corpus_counter = None
         self.wordlist = None
+        self.shown_words = None
+        self.shown_weights = None
+        self.combined_size_length = None
         self._create_layout()
         self.on_corpus_change(None)
+        self.update_input_summary()
+        self.update_output_summary(None, None)
 
     def _new_webview(self):
-        HTML = '''
+        HTML = """
 <!doctype html>
 <html>
 <head>
 <meta charset="utf-8">
 <style>
 html, body {margin:0px;padding:0px;width:100%;height:100%;
-            cursor:default; -webkit-user-select: none; user-select: none; }
+            cursor:default; -webkit-user-select: none; user-select: none;
+            overflow: hidden;}
 span:hover {color:OrangeRed !important}
 span.selected {color:red !important}
 </style>
@@ -73,39 +90,49 @@ def _new_webview(self):
 <script src="resources/wordcloud2.js"></script>
 <script src="resources/wordcloud-script.js"></script>
 </body>
-</html>'''
+</html>"""
         if self.webview:
             self.mainArea.layout().removeWidget(self.webview)
+            # parent is still aware of the view so we need to remove it
+            # and remove it from parents tree
+            self.webview.deleteLater()
 
         class Bridge(QObject):
-            @pyqtSlot('QVariantList')
+            @pyqtSlot("QVariantList")
             def update_selection(_, words):
                 nonlocal webview
                 self.update_selection(words, webview)
 
         class Webview(gui.WebviewWidget):
             def update_selection(self, words):
-                self.evalJS('SELECTED_WORDS = {}; selectWords();'.format(list(words)))
+                self.evalJS(
+                    "SELECTED_WORDS = {}; selectWords();".format(list(words))
+                )
 
-        webview = self.webview = Webview(self.mainArea, Bridge())
+        webview = self.webview = Webview(self.mainArea, Bridge(), debug=False)
         webview.setHtml(HTML, webview.toFileURL(__file__))
         self.mainArea.layout().addWidget(webview)
 
     def _create_layout(self):
-        box = gui.widgetBox(self.controlArea, 'Info')
-        self.topic_info = gui.label(box, self, '%(n_topic_words)d words in a topic')
-        gui.label(box, self, '%(documents_info_str)s')
-
-        box = gui.widgetBox(self.controlArea, 'Cloud preferences')
-        gui.checkBox(box, self, 'words_color', 'Color words', callback=self.on_cloud_pref_change)
-        TILT_VALUES = ('no', '30°', '45°', '60°')
-        gui.valueSlider(box, self, 'words_tilt', label='Words tilt:',
-                        values=list(range(len(TILT_VALUES))),
-                        callback=self.on_cloud_pref_change,
-                        labelFormat=lambda x: TILT_VALUES[x])
-        gui.button(box, None, 'Regenerate word cloud', callback=self.on_cloud_pref_change)
-
-        box = gui.widgetBox(self.controlArea, 'Words && weights')
+        box = gui.widgetBox(self.controlArea, "Cloud preferences")
+        gui.checkBox(
+            box,
+            self,
+            "words_color",
+            "Color words",
+            callback=self.on_cloud_pref_change,
+        )
+        gui.valueSlider(
+            box,
+            self,
+            "words_tilt",
+            label="Words tilt:",
+            values=list(range(len(TILT_VALUES))),
+            callback=self.on_cloud_pref_change,
+            labelFormat=lambda x: TILT_VALUES[x],
+        )
+
+        box = gui.widgetBox(self.controlArea, "Words && weights")
 
         class TableView(gui.TableView):
             def __init__(self, parent):
@@ -126,8 +153,10 @@ def selectionChanged(self, selected, deselected):
                 nonlocal model, proxymodel
                 super().selectionChanged(selected, deselected)
                 if not self.__nope:
-                    words = {model[proxymodel.mapToSource(index).row()][1]
-                             for index in self.selectionModel().selectedIndexes()}
+                    words = {
+                        model[proxymodel.mapToSource(index).row()][1]
+                        for index in self.selectionModel().selectedIndexes()
+                    }
                     self._parent.update_selection(words, self)
 
             def update_selection(self, words):
@@ -141,81 +170,125 @@ def update_selection(self, words):
                 self.clearSelection()
                 self.selectionModel().select(
                     selection,
-                    QItemSelectionModel.Select | QItemSelectionModel.Rows)
+                    QItemSelectionModel.Select | QItemSelectionModel.Rows,
+                )
                 self.__nope = False
 
         view = self.tableview = TableView(self)
         model = self.tablemodel = PyTableModel(parent=self)
-        proxymodel = QSortFilterProxyModel(self, dynamicSortFilter=True,
-                                           sortCaseSensitivity=Qt.CaseInsensitive,
-                                           sortRole=Qt.EditRole)
+        proxymodel = QSortFilterProxyModel(
+            self,
+            dynamicSortFilter=True,
+            sortCaseSensitivity=Qt.CaseInsensitive,
+            sortRole=Qt.EditRole,
+        )
         proxymodel.setSourceModel(model)
-        model.setHorizontalHeaderLabels(['Weight', 'Word'])
+        model.setHorizontalHeaderLabels(["Weight", "Word"])
         view.setModel(proxymodel)
         box.layout().addWidget(view)
 
+    def define_colors(
+            self, words: List[str], weights: List[float]
+    ) -> Dict[str, str]:
+        if (self.topic is not None
+                and self.topic.attributes["topic-method-name"] == "LsiModel"):
+            # when topic and topic method is LSI then color
+            # positive and negative numbers
+            palette = TOPIC_COLORS if self.words_color else GRAY_TOPIC_COLORS
+            colors = {
+                word: palette[weight >= 0]
+                for word, weight in zip(words, weights)
+            }
+        else:
+            color_generator = cycle(
+                COLORS if self.words_color else GRAY_COLORS
+            )
+            colors = {word: next(color_generator) for word in words}
+        return colors
+
     def on_cloud_pref_change(self):
         if self.wordlist is None:
             return
         self._new_webview()
-        self.webview.evalJS('''OPTIONS["color"] = function () {
-            return %s[Math.floor(3 * Math.random())];;
-        }''' % (["#da1", "#629", "#787"]
-                if self.words_color else
-                ['#000', '#444', '#777', '#aaa']))
+
+        # Generate colors
+        colors_dict = self.define_colors(self.shown_words, self.shown_weights)
+
+        self.webview.evalJS(f"colorList = {colors_dict}")
+        # this function makes sure that word color is always same, so color
+        # of the word depends on its letters
+        self.webview.evalJS(
+            """OPTIONS["color"] = function (word) {
+            return colorList[word];
+            }"""
+        )
+        self.webview.evalJS(
+            f"textAreaEstimation = {self.combined_size_length}"
+        )
         tilt_ratio, tilt_amount = {
             0: (0, 0),
             1: (1, PI / 6),
             2: (1, PI / 4),
-            3: (.67, PI / 3),
+            3: (0.67, PI / 3),
         }[self.words_tilt]
-        self.webview.evalJS('OPTIONS["minRotation"] = {}; \
-                             OPTIONS["maxRotation"] = {};'.format(-tilt_amount, tilt_amount))
+        self.webview.evalJS(
+            'OPTIONS["minRotation"] = {}; \
+                             OPTIONS["maxRotation"] = {};'.format(
+                -tilt_amount, tilt_amount
+            )
+        )
         self.webview.evalJS('OPTIONS["rotateRatio"] = {};'.format(tilt_ratio))
-        self.webview.evalJS('''
-        OPTIONS["gridSize"] = function () {
-          return Math.round( 
-            Math.min(
-              document.getElementById("canvas").clientWidth,
-              document.getElementById("canvas").clientHeight
-            ) / 48
-          );
-        };''')
-        self.webview.evalJS('''
-        OPTIONS["weightFactor"] = function (size) {
-          return size * 
-            Math.min(
-              document.getElementById("canvas").clientWidth,
-              document.getElementById("canvas").clientHeight
-            ) / 512;
-        };''')
-        # Trigger cloud redrawing by constructing new webview, because everything else fail Macintosh
         self.webview.evalJS('OPTIONS["list"] = {};'.format(self.wordlist))
-        self.webview.evalJS('redrawWordCloud();')
+        self.webview.evalJS("redrawWordCloud();")
         self.webview.update_selection(self.selected_words)
 
-    def _repopulate_wordcloud(self, words, weights):
-        N_BEST = 200
-        words, weights = words[:N_BEST], weights[:N_BEST]
+    def _repopulate_wordcloud(
+        self, words: List[str], weights: List[float]
+    ) -> None:
+        """
+        This function prepare a word list and trigger a cloud replot.
+
+        Parameters
+        ----------
+        words
+            List of words to show.
+        weights
+            Words' weights
+        """
+        words, weights = words[:N_BEST_PLOTTED], weights[:N_BEST_PLOTTED]
+        self.shown_words, self.shown_weights = words, weights
+
         # Repopulate table
         self.tablemodel.wrap(list(zip(weights, words)))
         self.tableview.sortByColumn(0, Qt.DescendingOrder)
+
         # Reset wordcloud
+        if self.topic is not None:
+            # when weights are from topic negative weights should be treated
+            # as positive when calculating the font size
+            weights = np.abs(weights)
         weights = np.clip(weights, *np.percentile(weights, [2, 98]))
-        weights = scale(weights, 8, 40)
+        weights = scale(weights, 10, 40)
         self.wordlist = np.c_[words, weights].tolist()
+
+        # sometimes words are longer in average and word sizes in pt are bigger
+        # in average - with this parameter we combine this in size scaling
+        self.combined_size_length = sum([
+            len(word) * float(weight) for word, weight in
+            self.wordlist
+        ])
+
         self.on_cloud_pref_change()
 
     @Inputs.topic
     def on_topic_change(self, data):
         self.topic = data
-        self.topic_info.setVisible(data is not None)
 
     def _apply_topic(self):
         data = self.topic
         metas = data.domain.metas if data else []
         try:
-            col = next(i for i,var in enumerate(metas) if var.is_string)
+            col = next(i for i, var in enumerate(metas) if var.is_string)
         except StopIteration:
             words = np.zeros((0, 1))
         else:
@@ -223,8 +296,8 @@ def _apply_topic(self):
             self.n_topic_words = data.metas.shape[0]
         if data and data.W.any():
             weights = data.W[:]
-        elif data and 'weights' in data.domain:
-            weights = data.get_column_view(data.domain['weights'])[0]
+        elif data and "weights" in data.domain:
+            weights = data.get_column_view(data.domain["weights"])[0]
         else:
             weights = np.ones(len(words))
 
@@ -245,10 +318,12 @@ def create_weight_list(self):
             words, freq = self.word_frequencies()
             words = np.array(words)[:, None]
             w_count = np.array(freq)[:, None]
-            domain = Domain([ContinuousVariable('Word Count')],
-                            metas=[StringVariable('Word')])
+            domain = Domain(
+                [ContinuousVariable("Word Count")],
+                metas=[StringVariable("Word")],
+            )
             wc_table = Table.from_numpy(domain, X=w_count, metas=words)
-            wc_table.name = 'Word Counts'
+            wc_table.name = "Word Counts"
         self.Outputs.word_counts.send(wc_table)
 
     @Inputs.corpus
@@ -263,13 +338,9 @@ def on_corpus_change(self, data):
                 self.Info.bow_weights()
                 self.corpus_counter = Counter(bow_counts)
             else:
-                self.corpus_counter = Counter(w for doc in data.ngrams for w in doc)
-            n_docs, n_words = len(data), len(self.corpus_counter)
-
-        self.documents_info_str = (
-            '{} documents with {} words'.format(n_docs, n_words)
-            if data else '(no documents on input)')
-
+                self.corpus_counter = Counter(
+                    w for doc in data.ngrams for w in doc
+                )
         self.create_weight_list()
 
     def _bow_words(self):
@@ -283,15 +354,19 @@ def _bow_words(self):
 
         average_bows = {
             f.name: self.corpus.get_column_view(f)[0].mean()
-            for f in bow_features}
+            for f in bow_features
+        }
         return average_bows
 
     def _get_bow_variables(self):
         """
         Extract bow variables from data
         """
-        return [var for var in self.corpus.domain.variables
-                if var.attributes.get("bow-feature", False)]
+        return [
+            var
+            for var in self.corpus.domain.variables
+            if var.attributes.get("bow-feature", False)
+        ]
 
     def handleNewSignals(self):
         if self.topic is not None and len(self.topic):
@@ -300,14 +375,18 @@ def handleNewSignals(self):
             self._apply_corpus()
         else:
             self.clear()
+            self.update_input_summary()
             return
 
         self.Warning.topic_precedence(
-            shown=self.corpus is not None and self.topic is not None)
+            shown=self.corpus is not None and self.topic is not None
+        )
 
         if self.topic is not None or self.corpus is not None:
             if self.selected_words:
                 self.update_selection(self.selected_words)
+        self.commit()
+        self.update_input_summary()
 
     def clear(self):
         self._new_webview()
@@ -329,62 +408,94 @@ def update_selection(self, words, skip=None):
     def commit(self):
         out = None
         if self.corpus is not None:
-            rows = [i for i, doc in enumerate(self.corpus.ngrams)
-                    if any(word in doc for word in self.selected_words)]
+            rows = [
+                i
+                for i, doc in enumerate(self.corpus.ngrams)
+                if any(word in doc for word in self.selected_words)
+            ]
             out = self.corpus[rows]
         self.Outputs.corpus.send(out)
 
         topic = None
         words = list(self.selected_words)
         if words:
-            topic = Topic.from_numpy(Domain([], metas=[StringVariable('Words')]),
-                                     X=np.empty((len(words), 0)),
-                                     metas=np.c_[words].astype(object))
-            topic.name = 'Selected Words'
+            topic = Topic.from_numpy(
+                Domain([], metas=[StringVariable("Words")]),
+                X=np.empty((len(words), 0)),
+                metas=np.c_[words].astype(object),
+            )
+            topic.name = "Selected Words"
         self.Outputs.selected_words.send(topic)
+        self.update_output_summary(
+            len(out) if out is not None else None,
+            len(topic) if topic is not None else None,
+        )
+
+    def update_input_summary(self) -> None:
+        if self.corpus is None and self.topic is None:
+            self.info.set_input_summary(self.info.NoInput)
+        else:
+            input_string = ""
+            input_numbers = ""
+            if self.corpus is not None:
+                input_string += (
+                    f"{len(self.corpus)} documents with "
+                    f"{len(self.corpus_counter)} words\n"
+                )
+                input_numbers += str(len(self.corpus_counter))
+            if self.topic is not None:
+                input_string += f"{self.n_topic_words} words in a topic."
+                input_numbers += (
+                    f"{' | ' if input_numbers else ''}" f"{self.n_topic_words}"
+                )
+            self.info.set_input_summary(input_numbers, input_string)
+
+    def update_output_summary(
+        self, cor_output_len: Optional[int], n_selected: Optional[int]
+    ) -> None:
+        if (
+            cor_output_len is None
+            and n_selected is None
+            and (self.corpus_counter is None or len(self.corpus_counter) == 0)
+        ):
+            self.info.set_output_summary(self.info.NoOutput)
+        else:
+            cc_len = (
+                len(self.corpus_counter)
+                if self.corpus_counter is not None
+                else 0
+            )
+            input_numbers = f"{cor_output_len or 0} | {n_selected or 0} | " \
+                            f"{cc_len}"
+            input_string = (
+                f"{cor_output_len or 0} documents\n"
+                f"{n_selected or 0} selected words\n"
+                f"{cc_len} words with counts"
+            )
+            self.info.set_output_summary(input_numbers, input_string)
 
     def send_report(self):
-        html = self.webview.html()
-        start = html.index('>', html.index('<body')) + 1
-        end = html.index('</body>')
-        body = html[start:end]
-        # create an empty div of appropriate height to compensate for
-        # absolute positioning of words in the html
-        height = self.webview._evalJS("document.getElementById('canvas').clientHeight")
-        self.report_html += '<div style="position: relative; height: {}px;">{}</div>'.format(
-            height, body)
-
-        self.report_table(self.tableview)
-
-
-def main():
-    from Orange.data import Table, Domain, ContinuousVariable, StringVariable
-
-    words = 'hey~mr. tallyman tally~me banana daylight come and me wanna go home'
-    words = np.array([w.replace('~', ' ') for w in words.split()], dtype=object, ndmin=2).T
-    weights = np.random.random((len(words), 1))
-
-    data = np.zeros((len(words), 0))
-    metas = []
-    for i, w in enumerate(weights.T):
-        data = np.column_stack((data, words, w))
-        metas = metas + [StringVariable('Topic' + str(i)),
-                         ContinuousVariable('weights')]
-    domain = Domain([], metas=metas)
-    table = Table.from_numpy(domain,
-                             X=np.zeros((len(words), 0)),
-                             metas=data)
-    app = QApplication([''])
-    w = OWWordCloud()
-    w.on_topic_change(table)
-    domain = Domain([], metas=[StringVariable('text')])
-    data = Corpus(domain=domain, metas=np.array([[' '.join(words.flat)]]))
-    # data = Corpus.from_numpy(domain, X=np.zeros((1, 0)), metas=np.array([[' '.join(words.flat)]]))
-    w.on_corpus_change(data)
-    w.handleNewSignals()
-    w.show()
-    app.exec()
+        if self.webview:
+            html = self.webview.html()
+            start = html.index(">", html.index("<body")) + 1
+            end = html.index("</body>")
+            body = html[start:end]
+            # create an empty div of appropriate height to compensate for
+            # absolute positioning of words in the html
+            height = self.webview._evalJS(
+                "document.getElementById('canvas').clientHeight"
+            )
+            self.report_html += "<div style='position: relative; height: " \
+                                f"{height}px;'>{body}</div>"
+
+            self.report_table(self.tableview)
+
+    def sizeHint(self) -> QtCore.QSize:
+        return super().sizeHint().expandedTo(QSize(900, 500))
 
 
 if __name__ == "__main__":
-    main()
+    from orangewidget.utils.widgetpreview import WidgetPreview
+
+    corpus = Corpus.from_file("book-excerpts")
+    WidgetPreview(OWWordCloud).run(corpus)


Test patch:
diff --git a/orangecontrib/text/widgets/tests/test_owworldcloud.py b/orangecontrib/text/widgets/tests/test_owworldcloud.py
--- a/orangecontrib/text/widgets/tests/test_owworldcloud.py
+++ b/orangecontrib/text/widgets/tests/test_owworldcloud.py
@@ -1,10 +1,14 @@
 import unittest
+from unittest.mock import Mock
+
 import numpy as np
 
 from Orange.widgets.tests.base import WidgetTest
+from Orange.data import StringVariable, Domain
 from scipy.sparse import csr_matrix
 
 from orangecontrib.text.corpus import Corpus
+from orangecontrib.text.topics import Topic
 from orangecontrib.text.widgets.owwordcloud import OWWordCloud
 
 
@@ -13,6 +17,22 @@ def setUp(self):
         self.widget = self.create_widget(OWWordCloud)
         self.corpus = Corpus.from_file('deerwester')
 
+        self.topic = self.create_topic()
+
+    def create_topic(self):
+        words = [[f"a{i}"] for i in range(10)]
+        weights = list(range(10))
+        t = Topic.from_numpy(
+            Domain([], metas=[
+                StringVariable("Topic 1")
+            ]),
+            X=np.empty((10, 0)),
+            metas=np.array(words),
+            W=weights  #np.array(weights).reshape(-1, 1)
+        )
+        t.attributes["topic-method-name"] = "LsiModel"
+        return t
+
     def test_data(self):
         """
         Just basic test.
@@ -26,11 +46,8 @@ def test_empty_data(self):
         Widget crashes when receives zero length data.
         GH-244
         """
-        self.assertTrue(self.widget.documents_info_str == "(no documents on input)")
         self.send_signal(self.widget.Inputs.corpus, self.corpus)
-        self.assertTrue(self.widget.documents_info_str == "9 documents with 42 words")
         self.send_signal(self.widget.Inputs.corpus, self.corpus[:0])
-        self.assertTrue(self.widget.documents_info_str == "(no documents on input)")
 
     def test_bow_features(self):
         """
@@ -98,6 +115,62 @@ def test_bow_info(self):
         self.send_signal(self.widget.Inputs.corpus, None)
         self.assertFalse(self.widget.Info.bow_weights.is_shown())
 
+    def test_topic(self):
+        self.send_signal(self.widget.Inputs.topic, self.topic)
+
+        self.assertIsNotNone(self.widget.topic)
+        self.assertEqual("a0", self.widget.wordlist[0][0])
+        self.assertEqual(10, self.widget.wordlist[0][1])
+        self.assertEqual("a9", self.widget.wordlist[9][0])
+        self.assertEqual(40, self.widget.wordlist[9][1])
+
+        self.assertListEqual(
+            self.topic.metas[:, 0].tolist(), self.widget.shown_words.tolist())
+        np.testing.assert_array_almost_equal(self.topic.W, self.widget.shown_weights)
+
+    def test_input_summary(self):
+        insum = self.widget.info.set_input_summary = Mock()
+
+        self.send_signal(self.widget.Inputs.corpus, self.corpus)
+        insum.assert_called_with("42", "9 documents with 42 words\n")
+
+        self.send_signal(self.widget.Inputs.topic, self.topic)
+        insum.assert_called_with(
+            "42 | 10", "9 documents with 42 words\n10 words in a topic.")
+
+        self.send_signal(self.widget.Inputs.corpus, None)
+        insum.assert_called_with(f"10", "10 words in a topic.")
+
+        self.send_signal(self.widget.Inputs.topic, None)
+        insum.assert_called_with(self.widget.info.NoInput)
+
+        self.send_signal(self.widget.Inputs.topic, self.topic)
+        insum.assert_called_with(f"10", "10 words in a topic.")
+
+    def test_output_summary(self):
+        outsum = self.widget.info.set_output_summary = Mock()
+
+        self.send_signal(self.widget.Inputs.corpus, self.corpus)
+        outsum.assert_called_with(
+            "0 | 0 | 42", "0 documents\n0 selected words\n42 words with counts"
+        )
+
+        self.send_signal(self.widget.Inputs.topic, self.topic)
+        outsum.assert_called_with(
+            "0 | 0 | 42", "0 documents\n0 selected words\n42 words with counts"
+        )
+
+        self.send_signal(self.widget.Inputs.corpus, None)
+        outsum.assert_called_with(self.widget.info.NoOutput)
+
+        self.send_signal(self.widget.Inputs.topic, None)
+        outsum.assert_called_with(self.widget.info.NoOutput)
+
+    def test_send_report(self):
+        self.widget.send_report()
+        self.send_signal(self.widget.Inputs.corpus, self.corpus)
+        self.widget.send_report()
+
 
 if __name__ == "__main__":
     unittest.main()