diff --git a/res/icons/icons.qrc b/res/icons/icons.qrc index cfbe0e556023429890485c14b43b9779a4108ac8..1e3016df94ab4c91c9f41ed191f800230a3383a6 100644 --- a/res/icons/icons.qrc +++ b/res/icons/icons.qrc @@ -28,6 +28,7 @@ <file alias="settings_display_icon">noun_38960_cc.svg</file> <file alias="settings_app_icon">noun_42425_cc.svg</file> <file alias="settings_network_icon">noun_62146_cc.svg</file> + <file alias="explorer_icon">noun_101791_cc.svg</file> <file alias="connected">connected.svg</file> <file alias="weak_connect">weak_connect.svg</file> <file alias="disconnected">disconnected.svg</file> diff --git a/res/icons/noun_101791_cc.svg b/res/icons/noun_101791_cc.svg new file mode 100644 index 0000000000000000000000000000000000000000..dabc0b049409edc3e5ae3670064c9aadcdf30d80 --- /dev/null +++ b/res/icons/noun_101791_cc.svg @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + version="1.1" + x="0px" + y="0px" + viewBox="0 0 100 125" + enable-background="new 0 0 100 100" + xml:space="preserve" + id="svg2" + inkscape:version="0.91 r13725" + sodipodi:docname="noun_101791_cc.svg"><metadata + id="metadata14"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs + id="defs12" /><sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="777" + inkscape:window-height="480" + id="namedview10" + showgrid="false" + inkscape:zoom="1.888" + inkscape:cx="50" + inkscape:cy="62.5" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="0" + inkscape:current-layer="svg2" /><path + d="M 77.891855,62.286039 C 83.30181,41.246432 70.585007,19.72692 49.546735,14.316965 28.504455,8.9056726 6.9862794,21.622477 1.5749866,42.662085 -3.8349685,63.703028 8.8804994,85.219867 29.921443,90.632496 50.959714,96.042452 72.480563,83.326984 77.891855,62.286039 Z M 32.585649,80.269764 C 17.258111,76.328931 7.9968867,60.651155 11.937719,45.324953 15.879889,29.998751 31.554991,20.736191 46.879857,24.677023 62.207396,28.617856 71.469956,44.294295 67.527786,59.620497 63.585617,74.946699 47.913188,84.210597 32.585649,80.269764 Z m 64.940859,27.323826 c 0.0021,-0.001 0.0043,-0.002 0.0054,-0.002 -0.01741,0.0241 -0.04009,0.0494 -0.0588,0.0722 -0.01602,0.0227 -0.03074,0.0414 -0.04676,0.0642 -0.0022,0.001 -0.0043,0.001 -0.0064,0.004 -0.201856,0.24597 -0.458522,0.54942 -0.660378,0.75528 l -0.815433,0.83148 c -2.364767,2.41156 -5.464764,3.2056 -6.927202,1.77257 L 65.622872,88.163459 c 1.263259,-1.046699 2.932899,-2.573304 3.693528,-3.272441 l -0.0027,-0.0013 c 2.116125,-1.914271 2.855365,-3.03182 3.265757,-3.420823 0.818111,-0.772661 2.018541,-2.439628 2.418239,-2.870071 l 23.393699,22.925756 c 1.285982,1.26326 0.864889,3.82855 -0.8649,6.069 z M 30.801046,36.40862 c -5.951352,4.030396 -10.322628,11.609948 -8.509952,18.799161 0.661707,2.618755 -3.222981,3.823195 -3.883351,1.209787 -2.344715,-9.29598 2.997065,-18.605328 10.394814,-23.616923 2.211037,-1.497195 4.201505,2.116126 1.998489,3.607975 z" + id="path4" + inkscape:connector-curvature="0" /></svg> \ No newline at end of file diff --git a/res/ui/explorer_tab.ui b/res/ui/explorer_tab.ui new file mode 100644 index 0000000000000000000000000000000000000000..1fdfca6504d575258272ea6e605da2bb1dcae9d4 --- /dev/null +++ b/res/ui/explorer_tab.ui @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ExplorerTabWidget</class> + <widget class="QWidget" name="ExplorerTabWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>522</width> + <height>442</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="ExplorerView" name="graphicsView"> + <property name="viewportUpdateMode"> + <enum>QGraphicsView::BoundingRectViewportUpdate</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="topMargin"> + <number>6</number> + </property> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Steps</string> + </property> + </widget> + </item> + <item> + <widget class="QSlider" name="steps_slider"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="tickPosition"> + <enum>QSlider::TicksBothSides</enum> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="button_go"> + <property name="text"> + <string>Go</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>ExplorerView</class> + <extends>QGraphicsView</extends> + <header>sakia.gui.views</header> + </customwidget> + </customwidgets> + <resources> + <include location="../icons/icons.qrc"/> + </resources> + <connections/> + <slots> + <slot>reset()</slot> + <slot>search()</slot> + <slot>select_node()</slot> + </slots> +</ui> diff --git a/src/sakia/core/graph/constants.py b/src/sakia/core/graph/constants.py index 56930a665c8402aafce0971c6be666500b4ab827..d1e2cb9d279468d536e527db06f45a591b2cc5dd 100644 --- a/src/sakia/core/graph/constants.py +++ b/src/sakia/core/graph/constants.py @@ -4,6 +4,7 @@ from enum import Enum class ArcStatus(Enum): WEAK = 0 STRONG = 1 + ON_PATH = 2 class NodeStatus: diff --git a/src/sakia/core/graph/explorer_graph.py b/src/sakia/core/graph/explorer_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..7bc01bf41f9ddbace0aa23dedba529683e5f9364 --- /dev/null +++ b/src/sakia/core/graph/explorer_graph.py @@ -0,0 +1,95 @@ +import logging +import networkx +import asyncio +from PyQt5.QtCore import pyqtSignal +from .base_graph import BaseGraph +from sakia.core.graph.constants import ArcStatus, NodeStatus + + +class ExplorerGraph(BaseGraph): + + graph_changed = pyqtSignal() + + def __init__(self, app, community, nx_graph=None): + """ + Init ExplorerGraph instance + :param sakia.core.app.Application app: Application instance + :param sakia.core.community.Community community: Community instance + :param networkx.Graph nx_graph: The networkx graph + :return: + """ + super().__init__(app, community, nx_graph) + self.exploration_task = None + self.explored_identity = None + self.steps = 0 + + def start_exploration(self, identity, steps): + """ + Start exploration of the wot from given identity + :param sakia.core.registry.Identity identity: The identity source of exploration + :param int steps: The number of steps from identity to explore + """ + if self.exploration_task: + if self.explored_identity is not identity or steps != self.steps: + self.exploration_task.cancel() + else: + return + self.explored_identity = identity + self.steps = steps + self.exploration_task = asyncio.ensure_future(self._explore(identity, steps)) + + def stop_exploration(self): + """ + Stop current exploration task, if present. + """ + if self.exploration_task: + self.exploration_task.cancel() + self.exploration_task = None + + async def _explore(self, identity, steps): + """ + Scan graph recursively + :param sakia.core.registry.Identity identity: identity instance from where we start + :param int steps: The number of steps from given identity to explore + :return: False when the identity is added in the graph + """ + # functions keywords args are persistent... Need to reset it with None trick + logging.debug("search %s in " % identity.uid) + + explored = [] + explorable = {0: [identity]} + current_identity = identity + self.nx_graph.clear() + self.add_identity(current_identity, NodeStatus.HIGHLIGHTED) + self.graph_changed.emit() + for step in range(1, steps + 1): + explorable[step] = [] + + for step in range(0, steps): + while len(explorable[step]) > 0: + # for each pubkey connected... + if current_identity not in explored: + self.add_identity(current_identity, NodeStatus.NEUTRAL) + logging.debug("New identity explored : {pubkey}".format(pubkey=current_identity.pubkey[:5])) + self.graph_changed.emit() + + certifier_list = await current_identity.unique_valid_certifiers_of(self.app.identities_registry, + self.community) + await self.add_certifier_list(certifier_list, current_identity, identity) + logging.debug("New identity certifiers : {pubkey}".format(pubkey=current_identity.pubkey[:5])) + self.graph_changed.emit() + + certified_list = await current_identity.unique_valid_certified_by(self.app.identities_registry, + self.community) + await self.add_certified_list(certified_list, current_identity, identity) + logging.debug("New identity certified : {pubkey}".format(pubkey=current_identity.pubkey[:5])) + self.graph_changed.emit() + + for cert in certified_list + certifier_list: + if cert['identity'] not in explorable[step + 1]: + explorable[step + 1].append(cert['identity']) + + explored.append(current_identity) + logging.debug("New identity explored : {pubkey}".format(pubkey=current_identity.pubkey[:5])) + self.graph_changed.emit() + current_identity = explorable[step].pop() \ No newline at end of file diff --git a/src/sakia/gui/community_view.py b/src/sakia/gui/community_view.py index ac2261d00ae5da048449210deb291041e0ca96ed..5db08b0bb898ec57593cb1ac92cd9937ce34e607 100644 --- a/src/sakia/gui/community_view.py +++ b/src/sakia/gui/community_view.py @@ -11,14 +11,15 @@ from PyQt5.QtCore import pyqtSlot, QDateTime, QLocale, QEvent, QT_TRANSLATE_NOOP from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget, QMessageBox, QDialog, QPushButton, QTabBar, QAction -from sakia.gui.graphs.wot_tab import WotTabWidget -from sakia.gui.widgets import toast -from sakia.gui.widgets.dialogs import QAsyncMessageBox +from .graphs.wot_tab import WotTabWidget +from .widgets import toast +from .widgets.dialogs import QAsyncMessageBox from .certifications_tab import CertificationsTabWidget from .identities_tab import IdentitiesTabWidget from .informations_tab import InformationsTabWidget from .network_tab import NetworkTabWidget from .transactions_tab import TransactionsTabWidget +from .graphs.explorer_tab import ExplorerTabWidget from ..gen_resources.community_view_uic import Ui_CommunityWidget from ..tools.decorators import asyncify, once_at_a_time, cancel_once_task from ..tools.exceptions import MembershipNotFoundError, LookupFailureError, NoPeerAvailable @@ -36,6 +37,7 @@ class CommunityWidget(QWidget, Ui_CommunityWidget): _tab_network_label = QT_TRANSLATE_NOOP("CommunityWidget", "Network") _tab_informations_label = QT_TRANSLATE_NOOP("CommunityWidget", "Informations") _action_showinfo_text = QT_TRANSLATE_NOOP("CommunityWidget", "Show informations") + _action_explore_text = QT_TRANSLATE_NOOP("CommunityWidget", "Explore the Web of Trust") _action_publish_uid_text = QT_TRANSLATE_NOOP("CommunityWidget", "Publish UID") _action_revoke_uid_text = QT_TRANSLATE_NOOP("CommunityWidget", "Revoke UID") @@ -59,10 +61,12 @@ class CommunityWidget(QWidget, Ui_CommunityWidget): self.tab_informations = InformationsTabWidget(self.app) self.tab_certifications = CertificationsTabWidget(self.app) self.tab_network = NetworkTabWidget(self.app) + self.tab_explorer = ExplorerTabWidget(self.app) self.action_publish_uid = QAction(self.tr(CommunityWidget._action_publish_uid_text), self) self.action_revoke_uid = QAction(self.tr(CommunityWidget._action_revoke_uid_text), self) self.action_showinfo = QAction(self.tr(CommunityWidget._action_showinfo_text), self) + self.action_explorer = QAction(self.tr(CommunityWidget._action_explore_text), self) super().setupUi(self) @@ -94,6 +98,11 @@ class CommunityWidget(QWidget, Ui_CommunityWidget): QIcon(":/icons/informations_icon"), self.tr("Informations"))) self.toolbutton_menu.addAction(action_showinfo) + action_showexplorer = QAction(self.tr("Show explorer"), self.toolbutton_menu) + action_showexplorer.triggered.connect(lambda : self.show_closable_tab(self.tab_explorer, + QIcon(":/icons/explorer_icon"), self.tr("Explorer"))) + self.toolbutton_menu.addAction(action_showexplorer) + self.action_publish_uid.triggered.connect(self.publish_uid) self.toolbutton_menu.addAction(self.action_publish_uid) @@ -127,6 +136,7 @@ class CommunityWidget(QWidget, Ui_CommunityWidget): self.tab_identities.change_account(account, self.password_asker) self.tab_history.change_account(account, self.password_asker) self.tab_informations.change_account(account) + self.tab_explorer.change_account(account, self.password_asker) def change_community(self, community): self.cancel_once_tasks() @@ -136,6 +146,7 @@ class CommunityWidget(QWidget, Ui_CommunityWidget): self.tab_history.change_community(community) self.tab_identities.change_community(community) self.tab_informations.change_community(community) + self.tab_explorer.change_community(community) if self.community: self.community.network.new_block_mined.disconnect(self.refresh_block) diff --git a/src/sakia/gui/graphs/explorer_tab.py b/src/sakia/gui/graphs/explorer_tab.py new file mode 100644 index 0000000000000000000000000000000000000000..452e3fca8a1d8dcab5f84fc21e65cd7e56661581 --- /dev/null +++ b/src/sakia/gui/graphs/explorer_tab.py @@ -0,0 +1,111 @@ +import logging + +from PyQt5.QtCore import QEvent, pyqtSignal, QT_TRANSLATE_NOOP + +from ...tools.decorators import asyncify, once_at_a_time, cancel_once_task +from ...core.graph import ExplorerGraph +from .graph_tab import GraphTabWidget +from ...gen_resources.explorer_tab_uic import Ui_ExplorerTabWidget + + +class ExplorerTabWidget(GraphTabWidget, Ui_ExplorerTabWidget): + + money_sent = pyqtSignal() + + _search_placeholder = QT_TRANSLATE_NOOP("ExplorerTabWidget", "Research a pubkey, an uid...") + + def __init__(self, app): + """ + :param sakia.core.app.Application app: Application instance + """ + # construct from qtDesigner + super().__init__(app) + self.setupUi(self) + self.set_scene(self.graphicsView.scene()) + + self.account = None + self.community = None + self.password_asker = None + self.graph = None + self.app = app + self.draw_task = None + + # nodes list for menu from search + self.nodes = list() + + # create node metadata from account + self._current_identity = None + self.button_go.clicked.connect(self.go_clicked) + + def cancel_once_tasks(self): + cancel_once_task(self, self.refresh_informations_frame) + cancel_once_task(self, self.reset) + + def change_account(self, account, password_asker): + self.account = account + self.password_asker = password_asker + + def change_community(self, community): + self.community = community + if self.graph: + self.graph.stop_exploration() + self.graph = ExplorerGraph(self.app, self.community) + self.graph.graph_changed.connect(self.refresh) + self.reset() + + def go_clicked(self): + if self.graph: + self.graph.stop_exploration() + self.draw_graph(self._current_identity) + + def draw_graph(self, identity): + """ + Draw community graph centered on the identity + + :param sakia.core.registry.Identity identity: Graph node identity + """ + logging.debug("Draw graph - " + identity.uid) + + if self.community: + #Connect new identity + if self._current_identity != identity: + self._current_identity = identity + + self.graph.start_exploration(identity, self.steps_slider.value()) + + # draw graph in qt scene + self.graphicsView.scene().update_wot(self.graph.nx_graph, identity, self.steps_slider.maximum()) + + def refresh(self): + """ + Refresh graph scene to current metadata + """ + if self._current_identity: + # draw graph in qt scene + self.graphicsView.scene().update_wot(self.graph.nx_graph, self._current_identity, self.steps_slider.maximum()) + else: + self.reset() + + @once_at_a_time + @asyncify + async def reset(self, checked=False): + """ + Reset graph scene to wallet identity + """ + if self.account and self.community: + parameters = await self.community.parameters() + self.steps_slider.setMaximum(parameters['stepMax']) + self.steps_slider.setValue(int(0.33 * parameters['stepMax'])) + identity = await self.account.identity(self.community) + self.draw_graph(identity) + + def changeEvent(self, event): + """ + Intercepte LanguageChange event to translate UI + :param QEvent QEvent: Event + :return: + """ + if event.type() == QEvent.LanguageChange: + self.retranslateUi(self) + self.refresh() + return super().changeEvent(event) diff --git a/src/sakia/gui/graphs/graph_tab.py b/src/sakia/gui/graphs/graph_tab.py new file mode 100644 index 0000000000000000000000000000000000000000..57afd78a4edf0892904867b36781bdca89d9a4bb --- /dev/null +++ b/src/sakia/gui/graphs/graph_tab.py @@ -0,0 +1,232 @@ +from PyQt5.QtWidgets import QWidget, QDialog +from PyQt5.QtCore import pyqtSlot, QEvent, QLocale, QDateTime, pyqtSignal + +from ...tools.exceptions import MembershipNotFoundError +from ...tools.decorators import asyncify, once_at_a_time +from ...core.registry import BlockchainState +from ...gui.member import MemberDialog +from ...gui.certification import CertificationDialog +from ...gui.transfer import TransferMoneyDialog +from ...gui.contact import ConfigureContactDialog + + +class GraphTabWidget(QWidget): + + money_sent = pyqtSignal() + def __init__(self, app): + """ + :param sakia.core.app.Application app: Application instance + """ + super().__init__() + + self.password_asker = None + self.app = app + + def set_scene(self, scene): + # add scene events + scene.node_clicked.connect(self.handle_node_click) + scene.node_signed.connect(self.sign_node) + scene.node_transaction.connect(self.send_money_to_node) + scene.node_contact.connect(self.add_node_as_contact) + scene.node_member.connect(self.identity_informations) + scene.node_copy_pubkey.connect(self.copy_node_pubkey) + + @once_at_a_time + @asyncify + async def refresh_informations_frame(self): + parameters = self.community.parameters + try: + identity = await self.account.identity(self.community) + membership = identity.membership(self.community) + renew_block = membership['blockNumber'] + last_renewal = self.community.get_block(renew_block)['medianTime'] + expiration = last_renewal + parameters['sigValidity'] + except MembershipNotFoundError: + last_renewal = None + expiration = None + + certified = await identity.unique_valid_certified_by(self.app.identities_registry, self.community) + certifiers = await identity.unique_valid_certifiers_of(self.app.identities_registry, self.community) + if last_renewal and expiration: + date_renewal = QLocale.toString( + QLocale(), + QDateTime.fromTime_t(last_renewal).date(), QLocale.dateFormat(QLocale(), QLocale.LongFormat) + ) + date_expiration = QLocale.toString( + QLocale(), + QDateTime.fromTime_t(expiration).date(), QLocale.dateFormat(QLocale(), QLocale.LongFormat) + ) + + if self.account.pubkey in self.community.members_pubkeys(): + # set infos in label + self.label_general.setText( + self.tr(""" + <table cellpadding="5"> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + </table> + """).format( + self.account.name, self.account.pubkey, + self.tr("Membership"), + self.tr("Last renewal on {:}, expiration on {:}").format(date_renewal, date_expiration), + self.tr("Your web of trust"), + self.tr("Certified by {:} members; Certifier of {:} members").format(len(certifiers), + len(certified)) + ) + ) + else: + # set infos in label + self.label_general.setText( + self.tr(""" + <table cellpadding="5"> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + </table> + """).format( + self.account.name, self.account.pubkey, + self.tr("Not a member"), + self.tr("Last renewal on {:}, expiration on {:}").format(date_renewal, date_expiration), + self.tr("Your web of trust"), + self.tr("Certified by {:} members; Certifier of {:} members").format(len(certifiers), + len(certified)) + ) + ) + else: + # set infos in label + self.label_general.setText( + self.tr(""" + <table cellpadding="5"> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + <tr><td align="right"><b>{:}</b></td></tr> + <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> + </table> + """).format( + self.account.name, self.account.pubkey, + self.tr("Not a member"), + self.tr("Your web of trust"), + self.tr("Certified by {:} members; Certifier of {:} members").format(len(certifiers), + len(certified)) + ) + ) + + @pyqtSlot(str, dict) + def handle_node_click(self, pubkey, metadata): + self.draw_graph( + self.app.identities_registry.from_handled_data( + metadata['text'], + pubkey, + None, + BlockchainState.VALIDATED, + self.community + ) + ) + + @once_at_a_time + @asyncify + async def draw_graph(self, identity): + """ + Draw community graph centered on the identity + + :param sakia.core.registry.Identity identity: Graph node identity + """ + pass + + @once_at_a_time + @asyncify + async def reset(self, checked=False): + """ + Reset graph scene to wallet identity + """ + pass + + def refresh(self): + """ + Refresh graph scene to current metadata + """ + pass + + def select_node(self, index): + """ + Select node in graph when item is selected in combobox + """ + if index < 0 or index >= len(self.nodes): + return False + node = self.nodes[index] + metadata = {'id': node['pubkey'], 'text': node['uid']} + self.draw_graph( + self.app.identities_registry.from_handled_data( + metadata['text'], + metadata['id'], + None, + BlockchainState.VALIDATED, + self.community + ) + ) + + def identity_informations(self, pubkey, metadata): + identity = self.app.identities_registry.from_handled_data( + metadata['text'], + pubkey, + None, + BlockchainState.VALIDATED, + self.community + ) + dialog = MemberDialog(self.app, self.account, self.community, identity) + dialog.exec_() + + @asyncify + async def sign_node(self, pubkey, metadata): + identity = self.app.identities_registry.from_handled_data( + metadata['text'], + pubkey, + None, + BlockchainState.VALIDATED, + self.community + ) + await CertificationDialog.certify_identity(self.app, self.account, self.password_asker, + self.community, identity) + + @asyncify + async def send_money_to_node(self, pubkey, metadata): + identity = self.app.identities_registry.from_handled_data( + metadata['text'], + pubkey, + None, + BlockchainState.VALIDATED, + self.community + ) + result = await TransferMoneyDialog.send_money_to_identity(self.app, self.account, self.password_asker, + self.community, identity) + if result == QDialog.Accepted: + self.money_sent.emit() + + def copy_node_pubkey(self, pubkey): + cb = self.app.qapp.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(pubkey, mode=cb.Clipboard) + + def add_node_as_contact(self, pubkey, metadata): + # check if contact already exists... + if pubkey == self.account.pubkey \ + or pubkey in [contact['pubkey'] for contact in self.account.contacts]: + return False + dialog = ConfigureContactDialog(self.account, self.window(), {'name': metadata['text'], + 'pubkey': pubkey, + }) + result = dialog.exec_() + if result == QDialog.Accepted: + self.window().refresh_contacts() + + def changeEvent(self, event): + """ + Intercepte LanguageChange event to translate UI + :param QEvent QEvent: Event + :return: + """ + if event.type() == QEvent.LanguageChange: + self.retranslateUi(self) + self._auto_refresh(None) + self.refresh() + return super().changeEvent(event) diff --git a/src/sakia/gui/graphs/wot_tab.py b/src/sakia/gui/graphs/wot_tab.py index 311af103d0374774c2462a80ea99f7b0f3b1899a..5c54dee2ba34156e24fb9f4f8ebb34128a0dc5d6 100644 --- a/src/sakia/gui/graphs/wot_tab.py +++ b/src/sakia/gui/graphs/wot_tab.py @@ -1,24 +1,19 @@ import logging import asyncio -from PyQt5.QtWidgets import QWidget, QComboBox, QDialog -from PyQt5.QtCore import pyqtSlot, QEvent, QLocale, QDateTime, pyqtSignal, QT_TRANSLATE_NOOP +from PyQt5.QtWidgets import QComboBox +from PyQt5.QtCore import QEvent, pyqtSignal, QT_TRANSLATE_NOOP from ucoinpy.api import bma -from sakia.tools.exceptions import MembershipNotFoundError -from sakia.tools.decorators import asyncify, once_at_a_time, cancel_once_task -from sakia.core.graph import WoTGraph -from sakia.core.registry import BlockchainState -from sakia.gui.member import MemberDialog -from sakia.gui.certification import CertificationDialog -from sakia.gui.transfer import TransferMoneyDialog -from sakia.gui.contact import ConfigureContactDialog -from sakia.gen_resources.wot_tab_uic import Ui_WotTabWidget -from sakia.gui.widgets.busy import Busy -from sakia.tools.exceptions import NoPeerAvailable +from ...tools.decorators import asyncify, once_at_a_time, cancel_once_task +from ...core.graph import WoTGraph +from ...gen_resources.wot_tab_uic import Ui_WotTabWidget +from ...gui.widgets.busy import Busy +from ...tools.exceptions import NoPeerAvailable +from .graph_tab import GraphTabWidget -class WotTabWidget(QWidget, Ui_WotTabWidget): +class WotTabWidget(GraphTabWidget, Ui_WotTabWidget): money_sent = pyqtSignal() _search_placeholder = QT_TRANSLATE_NOOP("WotTabWidget", "Research a pubkey, an uid...") @@ -27,7 +22,7 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): """ :param sakia.core.app.Application app: Application instance """ - super().__init__() + super().__init__(app) # construct from qtDesigner self.setupUi(self) @@ -42,13 +37,7 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): self.busy = Busy(self.graphicsView) self.busy.hide() - #Â add scene events - self.graphicsView.scene().node_clicked.connect(self.handle_node_click) - self.graphicsView.scene().node_signed.connect(self.sign_node) - self.graphicsView.scene().node_transaction.connect(self.send_money_to_node) - self.graphicsView.scene().node_contact.connect(self.add_node_as_contact) - self.graphicsView.scene().node_member.connect(self.identity_informations) - self.graphicsView.scene().node_copy_pubkey.connect(self.copy_node_pubkey) + self.set_scene(self.graphicsView.scene()) self.account = None self.community = None @@ -91,105 +80,13 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): elif self.community: self.community.network.new_block_mined.connect(self.refresh) - @once_at_a_time - @asyncify - async def refresh_informations_frame(self): - parameters = self.community.parameters - try: - identity = await self.account.identity(self.community) - membership = identity.membership(self.community) - renew_block = membership['blockNumber'] - last_renewal = self.community.get_block(renew_block)['medianTime'] - expiration = last_renewal + parameters['sigValidity'] - except MembershipNotFoundError: - last_renewal = None - expiration = None - - certified = await identity.unique_valid_certified_by(self.app.identities_registry, self.community) - certifiers = await identity.unique_valid_certifiers_of(self.app.identities_registry, self.community) - if last_renewal and expiration: - date_renewal = QLocale.toString( - QLocale(), - QDateTime.fromTime_t(last_renewal).date(), QLocale.dateFormat(QLocale(), QLocale.LongFormat) - ) - date_expiration = QLocale.toString( - QLocale(), - QDateTime.fromTime_t(expiration).date(), QLocale.dateFormat(QLocale(), QLocale.LongFormat) - ) - - if self.account.pubkey in self.community.members_pubkeys(): - # set infos in label - self.label_general.setText( - self.tr(""" - <table cellpadding="5"> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - </table> - """).format( - self.account.name, self.account.pubkey, - self.tr("Membership"), - self.tr("Last renewal on {:}, expiration on {:}").format(date_renewal, date_expiration), - self.tr("Your web of trust"), - self.tr("Certified by {:} members; Certifier of {:} members").format(len(certifiers), - len(certified)) - ) - ) - else: - # set infos in label - self.label_general.setText( - self.tr(""" - <table cellpadding="5"> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - </table> - """).format( - self.account.name, self.account.pubkey, - self.tr("Not a member"), - self.tr("Last renewal on {:}, expiration on {:}").format(date_renewal, date_expiration), - self.tr("Your web of trust"), - self.tr("Certified by {:} members; Certifier of {:} members").format(len(certifiers), - len(certified)) - ) - ) - else: - # set infos in label - self.label_general.setText( - self.tr(""" - <table cellpadding="5"> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - <tr><td align="right"><b>{:}</b></td></tr> - <tr><td align="right"><b>{:}</b></td><td>{:}</td></tr> - </table> - """).format( - self.account.name, self.account.pubkey, - self.tr("Not a member"), - self.tr("Your web of trust"), - self.tr("Certified by {:} members; Certifier of {:} members").format(len(certifiers), - len(certified)) - ) - ) - - @pyqtSlot(dict) - def handle_node_click(self, metadata): - self.draw_graph( - self.app.identities_registry.from_handled_data( - metadata['text'], - metadata['id'], - None, - BlockchainState.VALIDATED, - self.community - ) - ) - @once_at_a_time @asyncify async def draw_graph(self, identity): """ Draw community graph centered on the identity - :param sakia.core.registry.Identity identity: Graph node identity + :param sakia.core.registry.Identity identity: Center identity """ logging.debug("Draw graph - " + identity.uid) self.busy.show() @@ -199,7 +96,7 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): # create empty graph instance graph = WoTGraph(self.app, self.community) - await graph.initialize(identity_account, identity_account) + await graph.initialize(identity, identity_account) # draw graph in qt scene self.graphicsView.scene().update_wot(graph.nx_graph, identity) @@ -208,7 +105,7 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): # add path from selected member to account member path = await graph.get_shortest_path_to_identity(identity_account, identity) if path: - self.graphicsView.scene().update_path(path) + self.graphicsView.scene().update_path(graph.nx_graph, path) self.busy.hide() @once_at_a_time @@ -257,78 +154,6 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): except NoPeerAvailable: pass - def select_node(self, index): - """ - Select node in graph when item is selected in combobox - """ - if index < 0 or index >= len(self.nodes): - return False - node = self.nodes[index] - metadata = {'id': node['pubkey'], 'text': node['uid']} - self.draw_graph( - self.app.identities_registry.from_handled_data( - metadata['text'], - metadata['id'], - None, - BlockchainState.VALIDATED, - self.community - ) - ) - - def identity_informations(self, metadata): - identity = self.app.identities_registry.from_handled_data( - metadata['text'], - metadata['id'], - None, - BlockchainState.VALIDATED, - self.community - ) - dialog = MemberDialog(self.app, self.account, self.community, identity) - dialog.exec_() - - @asyncify - async def sign_node(self, metadata): - identity = self.app.identities_registry.from_handled_data( - metadata['text'], - metadata['id'], - None, - BlockchainState.VALIDATED, - self.community - ) - await CertificationDialog.certify_identity(self.app, self.account, self.password_asker, - self.community, identity) - - @asyncify - async def send_money_to_node(self, metadata): - identity = self.app.identities_registry.from_handled_data( - metadata['text'], - metadata['id'], - None, - BlockchainState.VALIDATED, - self.community - ) - result = await TransferMoneyDialog.send_money_to_identity(self.app, self.account, self.password_asker, - self.community, identity) - if result == QDialog.Accepted: - self.money_sent.emit() - - def copy_node_pubkey(self, metadata): - cb = self.app.qapp.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(metadata['id'], mode=cb.Clipboard) - - def add_node_as_contact(self, metadata): - # check if contact already exists... - if metadata['id'] == self.account.pubkey \ - or metadata['id'] in [contact['pubkey'] for contact in self.account.contacts]: - return False - dialog = ConfigureContactDialog(self.account, self.window(), {'name': metadata['text'], - 'pubkey': metadata['id'], - }) - result = dialog.exec_() - if result == QDialog.Accepted: - self.window().refresh_contacts() - def retranslateUi(self, widget): """ Retranslate missing widgets from generated code diff --git a/src/sakia/gui/views/__init__.py b/src/sakia/gui/views/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3ca1e9f7ff0ac7bd3d3e7c9e4d8344dd36a87a54 100644 --- a/src/sakia/gui/views/__init__.py +++ b/src/sakia/gui/views/__init__.py @@ -0,0 +1,2 @@ +from .explorer import ExplorerView +from .wot import WotView \ No newline at end of file diff --git a/src/sakia/gui/views/arc.py b/src/sakia/gui/views/arc.py index 6282581119a9076500df2ca39548765d01bdc3c9..45b292382f73b69660cb1423f4a9e0470756257e 100644 --- a/src/sakia/gui/views/arc.py +++ b/src/sakia/gui/views/arc.py @@ -29,6 +29,8 @@ class Arc(QGraphicsLineItem): self.setAcceptedMouseButtons(Qt.NoButton) + self.paint_pen = self.pen_from_status() + # cursor change on hover self.setAcceptHoverEvents(True) self.setZValue(0) @@ -57,6 +59,21 @@ class Arc(QGraphicsLineItem): extra ) + def pen_from_status(self): + """ + Get a pen from current status + :return: + """ + # Draw the line itself + color = QColor() + style = Qt.SolidLine + if self.status == ArcStatus.STRONG: + color.setNamedColor('blue') + if self.status == ArcStatus.WEAK: + color.setNamedColor('salmon') + style = Qt.DashLine + return QPen(color, 1, style, Qt.RoundCap, Qt.RoundJoin) + def paint(self, painter, option, widget): """ Customize line adding an arrow head @@ -71,18 +88,9 @@ class Arc(QGraphicsLineItem): if qFuzzyCompare(line.length(), 0): return - # Draw the line itself - color = QColor() - style = Qt.SolidLine - if self.status == ArcStatus.STRONG: - color.setNamedColor('blue') - if self.status == ArcStatus.WEAK: - color.setNamedColor('salmon') - style = Qt.DashLine - - painter.setPen(QPen(color, 1, style, Qt.RoundCap, Qt.RoundJoin)) + painter.setPen(self.paint_pen) painter.drawLine(line) - painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.setPen(QPen(self.paint_pen.color(), 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) # Draw the arrows angle = math.acos(line.dx() / line.length()) @@ -94,7 +102,7 @@ class Arc(QGraphicsLineItem): hpy = line.p1().y() + (line.dy() / 2.0) head_point = QPointF(hpx, hpy) - painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + painter.setPen(QPen(self.paint_pen.color(), 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) destination_arrow_p1 = head_point + QPointF( math.sin(angle - math.pi / 3) * self.arrow_size, math.cos(angle - math.pi / 3) * self.arrow_size) @@ -102,7 +110,7 @@ class Arc(QGraphicsLineItem): math.sin(angle - math.pi + math.pi / 3) * self.arrow_size, math.cos(angle - math.pi + math.pi / 3) * self.arrow_size) - painter.setBrush(color) + painter.setBrush(self.paint_pen.color()) painter.drawPolygon(QPolygonF([head_point, destination_arrow_p1, destination_arrow_p2])) if self.metadata["confirmation_text"]: diff --git a/src/sakia/gui/views/explorer.py b/src/sakia/gui/views/explorer.py new file mode 100644 index 0000000000000000000000000000000000000000..26331f377e0e28f02bf4238f7dfb14ea432e05b4 --- /dev/null +++ b/src/sakia/gui/views/explorer.py @@ -0,0 +1,139 @@ +import networkx +from PyQt5.QtGui import QPainter, QWheelEvent +from PyQt5.QtCore import Qt, QPoint, pyqtSignal +from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene +from PyQt5.QtGui import QTransform, QColor, QPen, QBrush, QMouseEvent, QRadialGradient + +from .node import Node +from .arc import Arc + +import logging + + +class ExplorerView(QGraphicsView): + def __init__(self, parent=None): + """ + Create View to display scene + + :param parent: [Optional, default=None] Parent widget + """ + super().__init__(parent) + + self.setScene(Scene(self)) + + self.setCacheMode(QGraphicsView.CacheBackground) + self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate) + self.setRenderHint(QPainter.Antialiasing) + self.setRenderHint(QPainter.SmoothPixmapTransform) + + def wheelEvent(self, event: QWheelEvent): + """ + Zoom in/out on the mouse cursor + """ + # zoom only when CTRL key pressed + if (event.modifiers() & Qt.ControlModifier) == Qt.ControlModifier: + steps = event.angleDelta().y() / 15 / 8 + + if steps == 0: + event.ignore() + return + + # scale factor 1.25 + sc = pow(1.25, steps) + self.scale(sc, sc) + self.centerOn(self.mapToScene(event.pos())) + event.accept() + # act normally on scrollbar + else: + # transmit event to parent class wheelevent + super(QGraphicsView, self).wheelEvent(event) + + +class Scene(QGraphicsScene): + # This defines signals taking string arguments + node_hovered = pyqtSignal(str, name='nodeHovered') + node_clicked = pyqtSignal(str, dict, name='nodeClicked') + node_signed = pyqtSignal(str, dict, name='nodeSigned') + node_transaction = pyqtSignal(str, dict, name='nodeTransaction') + node_contact = pyqtSignal(str, dict, name='nodeContact') + node_member = pyqtSignal(str, dict, name='nodeMember') + node_copy_pubkey = pyqtSignal(str, name='nodeCopyPubkey') + + def __init__(self, parent=None): + """ + Create scene of the graph + + :param parent: [Optional, default=None] Parent view + """ + super(Scene, self).__init__(parent) + + self.lastDragPos = QPoint() + self.setItemIndexMethod(QGraphicsScene.NoIndex) + self.node_hovered.connect(self.display_path_to) + + # list of nodes in scene + self.nodes = dict() + self.arcs = dict() + self.nx_graph = None + self.identity = None + # axis of the scene for debug purpose + # self.addLine(-100, 0, 100, 0) + # self.addLine(0, -100, 0, 100) + + def update_wot(self, nx_graph, identity, dist_max): + """ + draw community graph + + :param networkx.Graph nx_graph: graph to draw + :param sakia.core.registry.Identity identity: the wot of the identity + :param dist_max: the dist_max to display + """ + # clear scene + self.clear() + self.nodes.clear() + self.arcs.clear() + self.identity = identity + self.nx_graph = nx_graph + # Programs available : neato, twopi, circo, fdp, + # Nice programs : twopi + try: + graph_pos = networkx.pygraphviz_layout(nx_graph, prog="twopi") + except OSError: + logging.debug("Twopi not found, fallback mode...") + graph_pos = networkx.spring_layout(nx_graph, scale=len(nx_graph.nodes())*12) + + # create networkx graph + for node in nx_graph.nodes(data=True): + v = Node(node, graph_pos, scale=2) + distance = networkx.shortest_path_length(nx_graph.to_undirected(), identity.pubkey, node[0]) + factor = int((dist_max + 1) / (distance + 1) * 100) + color = QColor('light grey').darker(factor) + v.setBrush(QBrush(color)) + v.text_item.setBrush(QBrush(QColor('dark grey').lighter(factor))) + self.addItem(v) + self.nodes[node[0]] = v + + for edge in nx_graph.edges(data=True): + edge[2]["confirmation_text"] = "" + arc = Arc(edge[0], edge[1], edge[2], graph_pos, scale=2) + self.addItem(arc) + self.arcs[(edge[0], edge[1])] = arc + + self.update() + + def display_path_to(self, id): + if id != self.identity.pubkey: + path = networkx.shortest_path(self.nx_graph.to_undirected(), self.identity.pubkey, id) + for arc in self.arcs.values(): + arc.paint_pen = arc.pen_from_status() + self.update(arc.boundingRect()) + + for node, next_node in zip(path[:-1], path[1:]): + if (node, next_node) in self.arcs: + arc = self.arcs[(node, next_node)] + elif (next_node, node) in self.arcs: + arc = self.arcs[(next_node, node)] + if arc: + arc.paint_pen = QPen(QColor('black'), 3, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + logging.debug("Update arc between {0} and {1}".format(node, next_node)) + self.update(arc.boundingRect()) diff --git a/src/sakia/gui/views/node.py b/src/sakia/gui/views/node.py index 3c9944bfc6bd5d3850f30183bf7998fa2568125f..4f6b110ef92de411dcfb2d0cab262aa9a9e59563 100644 --- a/src/sakia/gui/views/node.py +++ b/src/sakia/gui/views/node.py @@ -99,6 +99,7 @@ class Node(QGraphicsEllipseItem): :param event: scene hover event """ self.setCursor(Qt.ArrowCursor) + self.scene().node_hovered.emit(self.id) def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): """ diff --git a/src/sakia/tests/unit/core/graph/test_explorer_graph.py b/src/sakia/tests/unit/core/graph/test_explorer_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..5147f404073f72bf26bb0ee63524843cddcf31a7 --- /dev/null +++ b/src/sakia/tests/unit/core/graph/test_explorer_graph.py @@ -0,0 +1,190 @@ +import sys +import unittest +import asyncio +from asynctest.mock import Mock, CoroutineMock, patch +from PyQt5.QtCore import QLocale +from sakia.tests import QuamashTest +from sakia.core.graph import ExplorerGraph + + +class TestExplorerGraph(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + ## Graph to test : + ## - E + ## A - B - C - D + ## + ## Path : Between A and C + + self.idA = Mock(specs='core.registry.Identity') + self.idA.pubkey = "HnFcSms8jzwngtVomTTnzudZx7SHUQY8sVE1y8yBmULk" + self.idA.uid = "A" + self.idA.is_member = CoroutineMock(spec='core.registry.Identity.is_member', return_value=True) + + self.idB = Mock(specs='core.registry.Identity') + self.idB.pubkey = "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ" + self.idB.uid = "B" + self.idB.is_member = CoroutineMock(spec='core.registry.Identity.is_member', return_value=True) + + self.idC = Mock(specs='core.registry.Identity') + self.idC.pubkey = "FADxcH5LmXGmGFgdixSes6nWnC4Vb4pRUBYT81zQRhjn" + self.idC.uid = "C" + self.idC.is_member = CoroutineMock(spec='core.registry.Identity.is_member', return_value=False) + + self.idD = Mock(specs='core.registry.Identity') + self.idD.pubkey = "6R11KGpG6w5Z6JfiwaPf3k4BCMY4dwhjCdmjGpvn7Gz5" + self.idD.uid = "D" + self.idD.is_member = CoroutineMock(spec='core.registry.Identity.is_member', return_value=True) + + self.idE = Mock(specs='core.registry.Identity') + self.idE.pubkey = "CZVDEsM6pPNxhAvXApGM8MJ6ExBZVpc8PNVyDZ7hKxLu" + self.idE.uid = "E" + self.idE.is_member = CoroutineMock(spec='core.registry.Identity.is_member', return_value=False) + + self.idA.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certified_by', + return_value=[ + { + 'cert_time': 49800, + 'identity': self.idB, + 'block_number': 996 + } + ]) + self.idA.unique_valid_certifiers_of = CoroutineMock(spec='core.registry.Identity.certifiers_of', + return_value=[]) + + self.idB.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certified_by', + return_value=[ + { + 'cert_time': 49100, + 'identity': self.idC, + 'block_number': 990 + } + ]) + + self.idB.unique_valid_certifiers_of = CoroutineMock(spec='core.registry.Identity.certifiers_of', + return_value=[ + { + 'cert_time': 49800, + 'identity': self.idA, + 'block_number': 996 + } + ]) + + self.idC.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certifierd_by', + return_value=[ + { + 'cert_time': 49100, + 'identity': self.idD, + 'block_number': 990 + }, + { + 'cert_time': 49110, + 'identity': self.idE, + 'block_number': 990 + } + ]) + + self.idC.unique_valid_certifiers_of = CoroutineMock(spec='core.registry.Identity.certifiers_of', + return_value=[ + { + 'cert_time': 49100, + 'identity': self.idB, + 'block_number': 990 + } + ]) + + self.idD.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certified_by', + return_value=[ + ]) + self.idD.unique_valid_certifiers_of = CoroutineMock(spec='core.registry.Identity.certifiers_of', + return_value=[ + { + 'cert_time': 49100, + 'identity': self.idC, + 'block_number': 990 + }]) + + self.idE.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certified_by', + return_value=[ + ]) + self.idE.unique_valid_certifiers_of = CoroutineMock(spec='core.registry.Identity.certifiers_of', + return_value=[ + { + 'cert_time': 49100, + 'identity': self.idC, + 'block_number': 990 + }]) + + def tearDown(self): + self.tearDownQuamash() + + @patch('sakia.core.Application') + @patch('sakia.core.Community') + @patch('time.time', Mock(return_value=50000)) + def test_explore_full(self, app, community): + community.parameters = CoroutineMock(return_value = {'sigValidity': 1000}) + community.network.confirmations = Mock(side_effect=lambda n: 4 if 996 else None) + app.preferences = {'expert_mode': True} + + explorer_graph = ExplorerGraph(app, community) + + async def exec_test(): + await explorer_graph._explore(self.idB, 5) + self.assertEqual(len(explorer_graph.nx_graph.nodes()), 5) + self.assertEqual(len(explorer_graph.nx_graph.edges()), 4) + + self.lp.run_until_complete(exec_test()) + + @patch('sakia.core.Application') + @patch('sakia.core.Community') + @patch('time.time', Mock(return_value=50000)) + def test_explore_partial(self, app, community): + community.parameters = CoroutineMock(return_value = {'sigValidity': 1000}) + community.network.confirmations = Mock(side_effect=lambda n: 4 if 996 else None) + app.preferences = {'expert_mode': True} + + explorer_graph = ExplorerGraph(app, community) + + async def exec_test(): + await explorer_graph._explore(self.idB, 1) + self.assertEqual(len(explorer_graph.nx_graph.nodes()), 3) + self.assertEqual(len(explorer_graph.nx_graph.edges()), 2) + + self.lp.run_until_complete(exec_test()) + + @patch('sakia.core.Application') + @patch('sakia.core.Community') + @patch('time.time', Mock(return_value=50000)) + def test_start_stop_exploration(self, app, community): + async def explore_mock(id, steps): + await asyncio.sleep(0.1) + await asyncio.sleep(0.1) + await asyncio.sleep(0.1) + + explorer_graph = ExplorerGraph(app, community) + explorer_graph._explore = explore_mock + + async def exec_test(): + self.assertEqual(explorer_graph.exploration_task, None) + explorer_graph.start_exploration(self.idA, 1) + self.assertNotEqual(explorer_graph.exploration_task, None) + task = explorer_graph.exploration_task + explorer_graph.start_exploration(self.idA, 1) + self.assertEqual(task, explorer_graph.exploration_task) + explorer_graph.start_exploration(self.idB, 1) + await asyncio.sleep(0) + self.assertTrue(task.cancelled()) + self.assertNotEqual(task, explorer_graph.exploration_task) + task2 = explorer_graph.exploration_task + explorer_graph.start_exploration(self.idB, 2) + await asyncio.sleep(0) + self.assertTrue(task2.cancelled()) + task3 = explorer_graph.exploration_task + explorer_graph.stop_exploration() + await asyncio.sleep(0) + self.assertTrue(task2.cancelled()) + + + self.lp.run_until_complete(exec_test())