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..58aa9891db98f11f4f66fcf45a3be4913937e123 --- /dev/null +++ b/res/ui/explorer_tab.ui @@ -0,0 +1,95 @@ +<?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="SearchUserWidget" name="search_user_widget" native="true"/> + </item> + <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> + <customwidget> + <class>SearchUserWidget</class> + <extends>QWidget</extends> + <header>sakia.gui.widgets.search_user</header> + <container>1</container> + </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/res/ui/search_user_view.ui b/res/ui/search_user_view.ui new file mode 100644 index 0000000000000000000000000000000000000000..ac85b8f6315e27911a64ed37dd722ee9bae882e8 --- /dev/null +++ b/res/ui/search_user_view.ui @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>SearchUserWidget</class> + <widget class="QWidget" name="SearchUserWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>400</width> + <height>44</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QComboBox" name="combobox_search"> + <property name="editable"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="button_reset"> + <property name="maximumSize"> + <size> + <width>85</width> + <height>27</height> + </size> + </property> + <property name="toolTip"> + <string>Center the view on me</string> + </property> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset resource="../icons/icons.qrc"> + <normaloff>:/icons/home_icon</normaloff>:/icons/home_icon</iconset> + </property> + </widget> + </item> + </layout> + </widget> + <resources> + <include location="../icons/icons.qrc"/> + </resources> + <connections/> +</ui> diff --git a/res/ui/wot_tab.ui b/res/ui/wot_tab.ui index 4175fed16225067b2a604095fda034c93f571fb3..d71654c11480961021b3679b84a89243a1b1e3b2 100644 --- a/res/ui/wot_tab.ui +++ b/res/ui/wot_tab.ui @@ -14,33 +14,6 @@ <string>Form</string> </property> <layout class="QGridLayout" name="gridLayout"> - <item row="0" column="0"> - <widget class="QComboBox" name="comboBoxSearch"> - <property name="editable"> - <bool>true</bool> - </property> - </widget> - </item> - <item row="0" column="1"> - <widget class="QPushButton" name="pushButtonReset"> - <property name="maximumSize"> - <size> - <width>85</width> - <height>27</height> - </size> - </property> - <property name="toolTip"> - <string>Center the view on me</string> - </property> - <property name="text"> - <string/> - </property> - <property name="icon"> - <iconset resource="../icons/icons.qrc"> - <normaloff>:/icons/home_icon</normaloff>:/icons/home_icon</iconset> - </property> - </widget> - </item> <item row="1" column="0" colspan="2"> <widget class="WotView" name="graphicsView"> <property name="viewportUpdateMode"> @@ -48,6 +21,9 @@ </property> </widget> </item> + <item row="0" column="0" colspan="2"> + <widget class="SearchUserWidget" name="search_user_widget" native="true"/> + </item> </layout> </widget> <customwidgets> @@ -56,44 +32,17 @@ <extends>QGraphicsView</extends> <header>sakia.gui.views.wot</header> </customwidget> + <customwidget> + <class>SearchUserWidget</class> + <extends>QWidget</extends> + <header>sakia.gui.widgets.search_user</header> + <container>1</container> + </customwidget> </customwidgets> <resources> <include location="../icons/icons.qrc"/> </resources> - <connections> - <connection> - <sender>pushButtonReset</sender> - <signal>clicked()</signal> - <receiver>WotTabWidget</receiver> - <slot>reset()</slot> - <hints> - <hint type="sourcelabel"> - <x>516</x> - <y>23</y> - </hint> - <hint type="destinationlabel"> - <x>284</x> - <y>198</y> - </hint> - </hints> - </connection> - <connection> - <sender>comboBoxSearch</sender> - <signal>currentIndexChanged(int)</signal> - <receiver>WotTabWidget</receiver> - <slot>select_node()</slot> - <hints> - <hint type="sourcelabel"> - <x>215</x> - <y>22</y> - </hint> - <hint type="destinationlabel"> - <x>260</x> - <y>220</y> - </hint> - </hints> - </connection> - </connections> + <connections/> <slots> <slot>reset()</slot> <slot>search()</slot> diff --git a/src/sakia/core/graph/base_graph.py b/src/sakia/core/graph/base_graph.py index 85d0bae02b69a1202ced19bce21cec3bd9accea4..ee6443bacc72fb264a126f11821f7b97ba9a5782 100644 --- a/src/sakia/core/graph/base_graph.py +++ b/src/sakia/core/graph/base_graph.py @@ -4,7 +4,7 @@ import networkx from PyQt5.QtCore import QLocale, QDateTime, QObject from sakia.tools.exceptions import NoPeerAvailable from sakia.core.net.network import MAX_CONFIRMATIONS -from .constants import ArcStatus, NodeStatus +from .constants import EdgeStatus, NodeStatus class BaseGraph(QObject): @@ -35,9 +35,9 @@ class BaseGraph(QObject): # display validity status ts = time.time() if (time.time() - cert_time) > arc_strong: - return ArcStatus.WEAK + return EdgeStatus.WEAK else: - return ArcStatus.STRONG + return EdgeStatus.STRONG async def node_status(self, node_identity, account_identity): """ @@ -159,7 +159,7 @@ class BaseGraph(QObject): Add identity as a new node in graph :param identity identity: identity instance :param int status: Optional, default=None, Node status (see sakia.gui.views.wot) - :param list arcs: Optional, default=None, List of arcs (certified by identity) + :param list edges: Optional, default=None, List of edges (certified by identity) :param list connected: Optional, default=None, Public key list of the connected nodes around the identity :return: """ diff --git a/src/sakia/core/graph/constants.py b/src/sakia/core/graph/constants.py index 56930a665c8402aafce0971c6be666500b4ab827..0509ef036255884f5825211603af1812ac0d47b2 100644 --- a/src/sakia/core/graph/constants.py +++ b/src/sakia/core/graph/constants.py @@ -1,7 +1,7 @@ from enum import Enum -class ArcStatus(Enum): +class EdgeStatus(Enum): WEAK = 0 STRONG = 1 diff --git a/src/sakia/core/graph/explorer_graph.py b/src/sakia/core/graph/explorer_graph.py index 73fa6f8adc654e52301e91a850f8dc9854a673a6..420b4b48a4cc9b106188fd30cb9d3cbad434231e 100644 --- a/src/sakia/core/graph/explorer_graph.py +++ b/src/sakia/core/graph/explorer_graph.py @@ -3,12 +3,13 @@ import networkx import asyncio from PyQt5.QtCore import pyqtSignal from .base_graph import BaseGraph -from sakia.core.graph.constants import ArcStatus, NodeStatus +from sakia.core.graph.constants import EdgeStatus, NodeStatus class ExplorerGraph(BaseGraph): graph_changed = pyqtSignal() + current_identity_changed = pyqtSignal(str) def __init__(self, app, community, nx_graph=None): """ @@ -21,6 +22,7 @@ class ExplorerGraph(BaseGraph): super().__init__(app, community, nx_graph) self.exploration_task = None self.explored_identity = None + self.steps = 0 def start_exploration(self, identity, steps): """ @@ -29,11 +31,13 @@ class ExplorerGraph(BaseGraph): :param int steps: The number of steps from identity to explore """ if self.exploration_task: - if self.explored_identity is not identity: + if self.explored_identity is not identity or steps != self.steps: self.exploration_task.cancel() else: return + self.nx_graph.clear() self.explored_identity = identity + self.steps = steps self.exploration_task = asyncio.ensure_future(self._explore(identity, steps)) def stop_exploration(self): @@ -42,6 +46,7 @@ class ExplorerGraph(BaseGraph): """ if self.exploration_task: self.exploration_task.cancel() + self.exploration_task = None async def _explore(self, identity, steps): """ @@ -56,13 +61,18 @@ class ExplorerGraph(BaseGraph): 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: + current_identity = explorable[step].pop() # for each pubkey connected... if current_identity not in explored: + self.current_identity_changed.emit(current_identity.pubkey) self.add_identity(current_identity, NodeStatus.NEUTRAL) logging.debug("New identity explored : {pubkey}".format(pubkey=current_identity.pubkey[:5])) self.graph_changed.emit() @@ -86,4 +96,4 @@ class ExplorerGraph(BaseGraph): 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 + self.current_identity_changed.emit("") diff --git a/src/sakia/core/registry/identity.py b/src/sakia/core/registry/identity.py index e36f11e105d509a1344a3127bce6f180421821d8..ce0e1d7757c284c59c612c4f4e7b2b7964911826 100644 --- a/src/sakia/core/registry/identity.py +++ b/src/sakia/core/registry/identity.py @@ -299,7 +299,7 @@ class Identity(QObject): certifiers.append(certifier) except ValueError as e: if '404' in str(e): - logging.debug('bma.wot.CertifiersOf request error: {0}'.format(str(e))) + logging.debug('bma.wot.CertifiersOf request error') else: logging.debug(str(e)) except NoPeerAvailable as e: @@ -392,7 +392,7 @@ class Identity(QObject): certified_list.append(certified) except ValueError as e: if '404' in str(e): - logging.debug('bma.wot.CertifiersOf request error') + logging.debug('bma.wot.CertifiedBy request error') except NoPeerAvailable as e: logging.debug(str(e)) 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..75321719cb4994d532b1b4e070209e5a437735a5 --- /dev/null +++ b/src/sakia/gui/graphs/explorer_tab.py @@ -0,0 +1,120 @@ +import logging + +from PyQt5.QtCore import QEvent, pyqtSignal, QT_TRANSLATE_NOOP + +from ucoinpy.api import bma + +from ...tools.decorators import asyncify, once_at_a_time, cancel_once_task +from ...tools.exceptions import NoPeerAvailable +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() + + def __init__(self, app): + """ + :param sakia.core.app.Application app: Application instance + """ + # construct from qtDesigner + super().__init__(app) + self.setupUi(self) + self.search_user_widget.init(app) + + 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) + self.search_user_widget.identity_selected.connect(self.draw_graph) + self.search_user_widget.reset.connect(self.reset) + + 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.search_user_widget.change_account(account) + 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.search_user_widget.change_community(community) + self.graph.current_identity_changed.connect(self.graphicsView.scene().update_current_identity) + 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().clear() + 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..6b4fee5cf6f54a5aabdd71522ee9355c546e3ca4 --- /dev/null +++ b/src/sakia/gui/graphs/graph_tab.py @@ -0,0 +1,214 @@ +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 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 ac9b934e6f0239b4bf0fe49436f8c2606791f93b..ce12bd290e659bde210edc643d3f2d9b80427e2b 100644 --- a/src/sakia/gui/graphs/wot_tab.py +++ b/src/sakia/gui/graphs/wot_tab.py @@ -1,54 +1,30 @@ import logging import asyncio -from PyQt5.QtWidgets import QWidget, QComboBox, QDialog -from PyQt5.QtCore import pyqtSlot, QEvent, QLocale, QDateTime, pyqtSignal, QT_TRANSLATE_NOOP -from ucoinpy.api import bma - -from ...tools.exceptions import MembershipNotFoundError +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 WoTGraph -from ...core.registry import BlockchainState -from ..member import MemberDialog -from ..certification import CertificationDialog -from ..transfer import TransferMoneyDialog -from ..contact import ConfigureContactDialog from ...gen_resources.wot_tab_uic import Ui_WotTabWidget -from ..widgets.busy import Busy -from ...tools.exceptions import NoPeerAvailable +from ...gui.widgets.busy import Busy +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...") def __init__(self, app): """ :param sakia.core.app.Application app: Application instance """ - super().__init__() + super().__init__(app) # construct from qtDesigner self.setupUi(self) - - # Default text when combo lineEdit is empty - self.comboBoxSearch.lineEdit().setPlaceholderText(self.tr(WotTabWidget._search_placeholder)) - # Â add combobox events - self.comboBoxSearch.lineEdit().returnPressed.connect(self.search) - #Â To fix a recall of the same item with different case, - #Â the edited text is not added in the item list - self.comboBoxSearch.setInsertPolicy(QComboBox.NoInsert) - + self.search_user_widget.init(app) 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 @@ -56,8 +32,8 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): self.app = app self.draw_task = None - #Â nodes list for menu from search - self.nodes = list() + self.search_user_widget.identity_selected.connect(self.draw_graph) + self.search_user_widget.reset.connect(self.reset) # create node metadata from account self._current_identity = None @@ -69,15 +45,13 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): def change_account(self, account, password_asker): self.cancel_once_tasks() - if self.account is not None: - self.account.certification_accepted.disconnect(self.refresh) + self.search_user_widget.change_account(account) self.account = account self.password_asker = password_asker - if self.account is not None: - self.account.certification_accepted.connect(self.refresh) def change_community(self, community): self.cancel_once_tasks() + self.search_user_widget.change_community(community) self._auto_refresh(community) self.community = community self.reset() @@ -95,105 +69,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() @@ -203,7 +85,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) @@ -212,7 +94,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 @@ -234,112 +116,6 @@ class WotTabWidget(QWidget, Ui_WotTabWidget): else: self.reset() - @asyncify - async def search(self): - """ - Search nodes when return is pressed in combobox lineEdit - """ - text = self.comboBoxSearch.lineEdit().text() - - if len(text) < 2: - return False - try: - response = await self.community.bma_access.future_request(bma.wot.Lookup, {'search': text}) - - nodes = {} - for identity in response['results']: - nodes[identity['pubkey']] = identity['uids'][0]['uid'] - - if nodes: - self.nodes = list() - self.comboBoxSearch.clear() - self.comboBoxSearch.lineEdit().setText(text) - for pubkey, uid in nodes.items(): - self.nodes.append({'pubkey': pubkey, 'uid': uid}) - self.comboBoxSearch.addItem(uid) - self.comboBoxSearch.showPopup() - 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 - """ - self.comboBoxSearch.lineEdit().setPlaceholderText(self.tr(WotTabWidget._search_placeholder)) - super().retranslateUi(self) - def resizeEvent(self, event): self.busy.resize(event.size()) super().resizeEvent(event) 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/edges/__init__.py b/src/sakia/gui/views/edges/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..188e3acfd2ccf101092227b52be070f696cd68c4 --- /dev/null +++ b/src/sakia/gui/views/edges/__init__.py @@ -0,0 +1,2 @@ +from .wot_edge import WotEdge +from .explorer_edge import ExplorerEdge \ No newline at end of file diff --git a/src/sakia/gui/views/edges/base_edge.py b/src/sakia/gui/views/edges/base_edge.py new file mode 100644 index 0000000000000000000000000000000000000000..1a2ceb32870bfa6c2ffe421b62c4034e5150b21c --- /dev/null +++ b/src/sakia/gui/views/edges/base_edge.py @@ -0,0 +1,26 @@ +from PyQt5.QtWidgets import QGraphicsLineItem +from PyQt5.QtCore import Qt, QPointF + + +class BaseEdge(QGraphicsLineItem): + def __init__(self, source_node, destination_node, metadata, nx_pos): + """ + Create an arc between two nodes + + :param str source_node: Source node id of the arc + :param str destination_node: Destination node id of the arc + :param dict metadata: Edge metadata + :param dict nx_pos: The position generated by nx_graph + """ + super().__init__() + + self.metadata = metadata + self.source = source_node + self.destination = destination_node + + self.status = self.metadata['status'] + + self.source_point = QPointF(nx_pos[self.source][0], nx_pos[self.source][1]) + self.destination_point = QPointF(nx_pos[self.destination][0], nx_pos[self.destination][1]) + + self.setAcceptedMouseButtons(Qt.NoButton) diff --git a/src/sakia/gui/views/edges/explorer_edge.py b/src/sakia/gui/views/edges/explorer_edge.py new file mode 100644 index 0000000000000000000000000000000000000000..6f39bea9cc3fc8879eaa3575eadf0b5d2065b2c8 --- /dev/null +++ b/src/sakia/gui/views/edges/explorer_edge.py @@ -0,0 +1,154 @@ +from PyQt5.QtCore import Qt, QRectF, QLineF, QPointF, QSizeF, \ + qFuzzyCompare, QTimeLine +from PyQt5.QtGui import QColor, QPen, QPolygonF +import math +from .base_edge import BaseEdge +from ....core.graph.constants import EdgeStatus + + +class ExplorerEdge(BaseEdge): + def __init__(self, source_node, destination_node, metadata, nx_pos, steps, steps_max): + """ + Create an arc between two nodes + + :param Node source_node: Source node of the arc + :param Node destination_node: Destination node of the arc + :param dict metadata: Arc metadata + :param dict nx_pos: The position generated by nx_graph + :param int steps: The steps from the center identity + :param int steps_max: The steps max of the graph + """ + super().__init__(source_node, destination_node, metadata, nx_pos) + + self.source_point = self.destination_point + self.steps = steps + self.steps_max = steps_max + self.highlighted = False + + self.arrow_size = 5 + # cursor change on hover + self.setAcceptHoverEvents(True) + self.setZValue(0) + self._line_styles = { + EdgeStatus.STRONG: Qt.SolidLine, + EdgeStatus.WEAK: Qt.DashLine + } + self.timeline = None + + @property + def line_style(self): + return self._line_styles[self.status] + + # virtual function require subclassing + def boundingRect(self): + """ + Return the bounding rectangle size + + :return: QRectF + """ + if not self.source or not self.destination: + return QRectF() + pen_width = 1.0 + extra = (pen_width + self.arrow_size) / 2.0 + + return QRectF( + self.source_point, QSizeF( + self.destination_point.x() - self.source_point.x(), + self.destination_point.y() - self.source_point.y() + ) + ).normalized().adjusted( + -extra, + -extra, + extra, + extra + ) + + def paint(self, painter, option, widget): + """ + Customize line adding an arrow head + + :param QPainter painter: Painter instance of the item + :param option: Painter option of the item + :param widget: Widget instance + """ + if not self.source or not self.destination: + return + line = QLineF(self.source_point, self.destination_point) + if qFuzzyCompare(line.length(), 0): + return + + # Draw the line itself + color = QColor() + color.setHsv(120 - 60 / self.steps_max * self.steps, + 180 + 50 / self.steps_max * self.steps, + 150 + 80 / self.steps_max * self.steps) + if self.highlighted: + color.setHsv(0, 0, 0) + + style = self.line_style + + painter.setPen(QPen(color, 1, style, Qt.RoundCap, Qt.RoundJoin)) + painter.drawLine(line) + painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + + # Draw the arrows + angle = math.acos(line.dx() / line.length()) + if line.dy() >= 0: + angle = (2.0 * math.pi) - angle + + # arrow in the middle of the arc + hpx = line.p1().x() + (line.dx() / 2.0) + hpy = line.p1().y() + (line.dy() / 2.0) + head_point = QPointF(hpx, hpy) + + painter.setPen(QPen(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) + destination_arrow_p2 = head_point + QPointF( + 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.drawPolygon(QPolygonF([head_point, destination_arrow_p1, destination_arrow_p2])) + + if self.metadata["confirmation_text"]: + painter.drawText(head_point, self.metadata["confirmation_text"]) + + def move_source_point(self, node_id, x, y): + """ + Move to corresponding position + :param str node_id: the node id + :param float x: x coordinates + :param float y: y coordinates + :return: + """ + if node_id == self.source: + self.source_point = QPointF(x, y) + self.update(self.boundingRect()) + + def move_destination_point(self, node_id, x, y): + """ + Move to corresponding position + :param str node_id: the node id + :param float x: x coordinates + :param float y: y coordinates + :return: + """ + if node_id == self.destination: + self.destination_point = QPointF(x, y) + self.update(self.boundingRect()) + + def highlight(self): + """ + Highlight the edge in the scene + """ + self.highlighted = True + self.update(self.boundingRect()) + + def neutralize(self): + """ + Neutralize the edge in the scene + """ + self.highlighted = False + self.update(self.boundingRect()) diff --git a/src/sakia/gui/views/arc.py b/src/sakia/gui/views/edges/wot_edge.py similarity index 78% rename from src/sakia/gui/views/arc.py rename to src/sakia/gui/views/edges/wot_edge.py index 6282581119a9076500df2ca39548765d01bdc3c9..50fcf150c949e89e3f2d468606fdc16c63eb9da7 100644 --- a/src/sakia/gui/views/arc.py +++ b/src/sakia/gui/views/edges/wot_edge.py @@ -1,13 +1,13 @@ -from PyQt5.QtWidgets import QGraphicsLineItem from PyQt5.QtCore import Qt, QRectF, QLineF, QPointF, QSizeF, \ qFuzzyCompare from PyQt5.QtGui import QColor, QPen, QPolygonF import math -from ...core.graph.constants import ArcStatus +from .base_edge import BaseEdge +from ....core.graph.constants import EdgeStatus -class Arc(QGraphicsLineItem): - def __init__(self, source_node, destination_node, metadata, pos, scale=1): +class WotEdge(BaseEdge): + def __init__(self, source_node, destination_node, metadata, pos): """ Create an arc between two nodes @@ -15,23 +15,28 @@ class Arc(QGraphicsLineItem): :param Node destination_node: Destination node of the arc :param dict metadata: Arc metadata """ - super(Arc, self).__init__() - - self.metadata = metadata - self.source = source_node - self.destination = destination_node - - self.status = self.metadata['status'] - - self.source_point = QPointF(pos[self.source][0], pos[self.source][1])*scale - self.destination_point = QPointF(pos[self.destination][0], pos[self.destination][1])*scale - self.arrow_size = 5.0 - - self.setAcceptedMouseButtons(Qt.NoButton) + super().__init__(source_node, destination_node, metadata, pos) + self.arrow_size = 5 # cursor change on hover self.setAcceptHoverEvents(True) self.setZValue(0) + self._colors = { + EdgeStatus.STRONG: 'blue', + EdgeStatus.WEAK: 'salmon' + } + self._line_styles = { + EdgeStatus.STRONG: Qt.SolidLine, + EdgeStatus.WEAK: Qt.DashLine + } + + @property + def color_name(self): + return self._colors[self.status] + + @property + def line_style(self): + return self._line_styles[self.status] # virtual function require subclassing def boundingRect(self): @@ -73,12 +78,8 @@ class Arc(QGraphicsLineItem): # 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 + color.setNamedColor(self.color_name) + style = self.line_style painter.setPen(QPen(color, 1, style, Qt.RoundCap, Qt.RoundJoin)) painter.drawLine(line) diff --git a/src/sakia/gui/views/explorer.py b/src/sakia/gui/views/explorer.py new file mode 100644 index 0000000000000000000000000000000000000000..927d9f085cb63c2c633a878a0c70cb4f380781f1 --- /dev/null +++ b/src/sakia/gui/views/explorer.py @@ -0,0 +1,47 @@ +import logging + +import networkx +from PyQt5.QtCore import Qt, QPoint, pyqtSignal +from PyQt5.QtGui import QPainter, QWheelEvent +from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene + +from .scenes import ExplorerScene + + +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(ExplorerScene(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) diff --git a/src/sakia/gui/views/nodes/__init__.py b/src/sakia/gui/views/nodes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..28332381ab8beea33a4eb493b6d21af09a582adb --- /dev/null +++ b/src/sakia/gui/views/nodes/__init__.py @@ -0,0 +1,2 @@ +from .wot_node import WotNode +from .explorer_node import ExplorerNode \ No newline at end of file diff --git a/src/sakia/gui/views/node.py b/src/sakia/gui/views/nodes/base_node.py similarity index 67% rename from src/sakia/gui/views/node.py rename to src/sakia/gui/views/nodes/base_node.py index 3c9944bfc6bd5d3850f30183bf7998fa2568125f..f486d1c556f02bb0d5876a8b953e0ebda91bd42e 100644 --- a/src/sakia/gui/views/node.py +++ b/src/sakia/gui/views/nodes/base_node.py @@ -1,14 +1,13 @@ from PyQt5.QtWidgets import QGraphicsEllipseItem, \ - QGraphicsSimpleTextItem, QMenu, QAction, QGraphicsSceneHoverEvent, \ + QMenu, QAction, QGraphicsSceneHoverEvent, \ QGraphicsSceneContextMenuEvent +from PyQt5.QtCore import Qt, QCoreApplication, QT_TRANSLATE_NOOP, pyqtSignal +from PyQt5.QtGui import QMouseEvent +from sakia.core.graph.constants import NodeStatus -from PyQt5.QtCore import Qt, QPointF, QCoreApplication, QT_TRANSLATE_NOOP -from PyQt5.QtGui import QTransform, QColor, QPen, QBrush, QMouseEvent, QRadialGradient -from ...core.graph.constants import NodeStatus - -class Node(QGraphicsEllipseItem): - def __init__(self, nx_node, pos, scale=1): +class BaseNode(QGraphicsEllipseItem): + def __init__(self, nx_node, pos): """ Create node in the graph scene @@ -16,14 +15,13 @@ class Node(QGraphicsEllipseItem): :param x_y: Position of the node """ - super(Node, self).__init__() + super().__init__() self.metadata = nx_node[1] self.id = nx_node[0] # unpack tuple x, y = pos[nx_node[0]] - x *= scale - y *= scale + self.setPos(x, y) self.status_wallet = self.metadata['status'] & NodeStatus.HIGHLIGHTED self.status_member = not self.metadata['status'] & NodeStatus.OUT self.text = self.metadata['text'] @@ -38,50 +36,6 @@ class Node(QGraphicsEllipseItem): self.action_contact = None self.action_show_member = None - # color around ellipse - outline_color = QColor('grey') - outline_style = Qt.SolidLine - outline_width = 1 - if self.status_wallet: - outline_color = QColor('black') - outline_width = 2 - if not self.status_member: - outline_color = QColor('red') - outline_style = Qt.SolidLine - self.setPen(QPen(outline_color, outline_width, outline_style)) - - # text inside ellipse - self.text_item = QGraphicsSimpleTextItem(self) - self.text_item.setText(self.text) - text_color = QColor('grey') - if self.status_wallet == NodeStatus.HIGHLIGHTED: - text_color = QColor('black') - self.text_item.setBrush(QBrush(text_color)) - # center ellipse around text - self.setRect( - 0, - 0, - self.text_item.boundingRect().width() * 2, - self.text_item.boundingRect().height() * 2 - ) - - # set anchor to the center - self.setTransform( - QTransform().translate(-self.boundingRect().width() / 2.0, -self.boundingRect().height() / 2.0)) - self.setPos(x, y) - # center text in ellipse - self.text_item.setPos(self.boundingRect().width() / 4.0, self.boundingRect().height() / 4.0) - - # create gradient inside the ellipse - gradient = QRadialGradient(QPointF(0, self.boundingRect().height() / 4), self.boundingRect().width()) - gradient.setColorAt(0, QColor('white')) - gradient.setColorAt(1, QColor('darkgrey')) - self.setBrush(QBrush(gradient)) - - # cursor change on hover - self.setAcceptHoverEvents(True) - self.setZValue(1) - def mousePressEvent(self, event: QMouseEvent): """ Click on mouse button @@ -99,6 +53,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/gui/views/nodes/explorer_node.py b/src/sakia/gui/views/nodes/explorer_node.py new file mode 100644 index 0000000000000000000000000000000000000000..bedc9b9df18957407021d3a5e1058122b7cfa3ac --- /dev/null +++ b/src/sakia/gui/views/nodes/explorer_node.py @@ -0,0 +1,167 @@ +from PyQt5.QtWidgets import QGraphicsSimpleTextItem +from PyQt5.QtCore import Qt, QPointF, QTimeLine, QTimer +from PyQt5.QtGui import QTransform, QColor, QPen, QBrush, QRadialGradient +from ....core.graph.constants import NodeStatus +from .base_node import BaseNode +import logging +import math + + +class ExplorerNode(BaseNode): + def __init__(self, nx_node, center_pos, nx_pos, steps, steps_max): + """ + Create node in the graph scene + + :param tuple nx_node: Node info + :param center_pos: The position of the center node + :param nx_pos: Position of the nodes in the graph + :param int steps: The steps from the center identity + :param int steps_max: The steps max of the graph + """ + super().__init__(nx_node, nx_pos) + + self.steps = steps + self.steps_max = steps_max + self.highlighted = False + + # text inside ellipse + self.text_item = QGraphicsSimpleTextItem(self) + self.text_item.setText(self.text) + # center ellipse around text + self.setRect( + 0, + 0, + self.text_item.boundingRect().width() * 2, + self.text_item.boundingRect().height() * 2 + ) + + # set anchor to the center + self.setTransform( + QTransform().translate(-self.boundingRect().width() / 2.0, -self.boundingRect().height() / 2.0)) + # center text in ellipse + self.text_item.setPos(self.boundingRect().width() / 4.0, self.boundingRect().height() / 4.0) + + # cursor change on hover + self.setAcceptHoverEvents(True) + self.setZValue(1) + + # animation and moves + self.timeline = None + self.loading_timer = QTimer() + self.loading_timer.timeout.connect(self.next_tick) + self.loading_counter = 0 + self._refresh_colors() + self.setPos(center_pos) + self.move_to(nx_pos) + + def _refresh_colors(self): + """ + Refresh elements in the node + """ + # color around ellipse + outline_color = QColor('black') + outline_style = Qt.SolidLine + outline_width = 1 + if self.status_wallet: + outline_color = QColor('grey') + outline_width = 2 + if not self.status_member: + outline_color = QColor('red') + outline_style = Qt.SolidLine + self.setPen(QPen(outline_color, outline_width, outline_style)) + + if self.highlighted: + text_color = QColor('grey') + else: + text_color = QColor('black') + + if self.status_wallet == NodeStatus.HIGHLIGHTED: + text_color = QColor('grey') + self.text_item.setBrush(QBrush(text_color)) + + # create gradient inside the ellipse + gradient = QRadialGradient(QPointF(0, self.boundingRect().height() / 4), self.boundingRect().width()) + color = QColor() + color.setHsv(120 - 60 / self.steps_max * self.steps, + 180 + 50 / self.steps_max * self.steps, + 60 + 170 / self.steps_max * self.steps) + if self.highlighted: + color = color.darker(200) + color = color.lighter(math.fabs(math.sin(self.loading_counter / 100 * math.pi) * 100) + 100) + gradient.setColorAt(0, color) + gradient.setColorAt(1, color.darker(150)) + self.setBrush(QBrush(gradient)) + + def move_to(self, nx_pos): + """ + Move to corresponding position + :param nx_pos: + :return: + """ + origin_x = self.x() + origin_y = self.y() + final_x = nx_pos[self.id][0] + final_y = nx_pos[self.id][1] + + def frame_move(frame): + value = self.timeline.valueForTime(self.timeline.currentTime()) + x = origin_x + (final_x - origin_x) * value + y = origin_y + (final_y - origin_y) * value + self.setPos(x, y) + self.scene().node_moved.emit(self.id, x, y) + + def timeline_ends(): + self.setPos(final_x, final_y) + self.timeline = None + + # Remember to hold the references to QTimeLine and QGraphicsItemAnimation instances. + # They are not kept anywhere, even if you invoke QTimeLine.start(). + self.timeline = QTimeLine(1000) + self.timeline.setFrameRange(0, 100) + self.timeline.frameChanged.connect(frame_move) + self.timeline.finished.connect(timeline_ends) + + self.timeline.start() + + def highlight(self): + """ + Highlight the edge in the scene + """ + self.highlighted = True + self._refresh_colors() + self.update(self.boundingRect()) + + def neutralize(self): + """ + Neutralize the edge in the scene + """ + self.highlighted = False + self._refresh_colors() + self.update(self.boundingRect()) + + def start_loading_animation(self): + """ + Neutralize the edge in the scene + """ + if not self.loading_timer.isActive(): + self.loading_timer.start(10) + + def stop_loading_animation(self): + """ + Neutralize the edge in the scene + """ + self.loading_timer.stop() + self.loading_counter = 100 + self._refresh_colors() + self.update(self.boundingRect()) + + def next_tick(self): + """ + Next tick + :return: + """ + self.loading_counter += 1 + self.loading_counter %= 100 + self._refresh_colors() + self.update(self.boundingRect()) + diff --git a/src/sakia/gui/views/nodes/wot_node.py b/src/sakia/gui/views/nodes/wot_node.py new file mode 100644 index 0000000000000000000000000000000000000000..ef7ba7dc1e0c43b631b05c28d032dbf0228cd646 --- /dev/null +++ b/src/sakia/gui/views/nodes/wot_node.py @@ -0,0 +1,59 @@ +from PyQt5.QtWidgets import QGraphicsSimpleTextItem +from PyQt5.QtCore import Qt, QPointF +from PyQt5.QtGui import QTransform, QColor, QPen, QBrush, QRadialGradient +from ....core.graph.constants import NodeStatus +from .base_node import BaseNode + + +class WotNode(BaseNode): + def __init__(self, nx_node, pos): + """ + Create node in the graph scene + + :param tuple nx_node: Node info + :param x_y: Position of the node + """ + super().__init__(nx_node, pos) + + # color around ellipse + outline_color = QColor('grey') + outline_style = Qt.SolidLine + outline_width = 1 + if self.status_wallet: + outline_color = QColor('black') + outline_width = 2 + if not self.status_member: + outline_color = QColor('red') + outline_style = Qt.SolidLine + self.setPen(QPen(outline_color, outline_width, outline_style)) + + # text inside ellipse + self.text_item = QGraphicsSimpleTextItem(self) + self.text_item.setText(self.text) + text_color = QColor('grey') + if self.status_wallet == NodeStatus.HIGHLIGHTED: + text_color = QColor('black') + self.text_item.setBrush(QBrush(text_color)) + # center ellipse around text + self.setRect( + 0, + 0, + self.text_item.boundingRect().width() * 2, + self.text_item.boundingRect().height() * 2 + ) + + # set anchor to the center + self.setTransform( + QTransform().translate(-self.boundingRect().width() / 2.0, -self.boundingRect().height() / 2.0)) + # center text in ellipse + self.text_item.setPos(self.boundingRect().width() / 4.0, self.boundingRect().height() / 4.0) + + # create gradient inside the ellipse + gradient = QRadialGradient(QPointF(0, self.boundingRect().height() / 4), self.boundingRect().width()) + gradient.setColorAt(0, QColor('white')) + gradient.setColorAt(1, QColor('darkgrey')) + self.setBrush(QBrush(gradient)) + + # cursor change on hover + self.setAcceptHoverEvents(True) + self.setZValue(1) diff --git a/src/sakia/gui/views/scenes/__init__.py b/src/sakia/gui/views/scenes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f2eef13ee6a02e87b65491aa2d076785a7fc84d1 --- /dev/null +++ b/src/sakia/gui/views/scenes/__init__.py @@ -0,0 +1,2 @@ +from .wot_scene import WotScene +from .explorer_scene import ExplorerScene \ No newline at end of file diff --git a/src/sakia/gui/views/scenes/base_scene.py b/src/sakia/gui/views/scenes/base_scene.py new file mode 100644 index 0000000000000000000000000000000000000000..7a4762b0c4926d4d9fc473c06b840c789828c3c6 --- /dev/null +++ b/src/sakia/gui/views/scenes/base_scene.py @@ -0,0 +1,16 @@ +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QGraphicsScene + + +class BaseScene(QGraphicsScene): + # This defines signals taking string arguments + node_clicked = pyqtSignal(str, dict) + node_signed = pyqtSignal(str, dict) + node_transaction = pyqtSignal(str, dict) + node_contact = pyqtSignal(str, dict) + node_member = pyqtSignal(str, dict) + node_copy_pubkey = pyqtSignal(str) + node_hovered = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) \ No newline at end of file diff --git a/src/sakia/gui/views/scenes/explorer_scene.py b/src/sakia/gui/views/scenes/explorer_scene.py new file mode 100644 index 0000000000000000000000000000000000000000..0c069ead7b04956cccec6cfa80f19c43715a0bab --- /dev/null +++ b/src/sakia/gui/views/scenes/explorer_scene.py @@ -0,0 +1,300 @@ +import networkx +import logging +import math +from PyQt5.QtCore import QPoint, pyqtSignal +from PyQt5.QtWidgets import QGraphicsScene + +from ..edges import ExplorerEdge +from ..nodes import ExplorerNode + +from .base_scene import BaseScene + + +class ExplorerScene(BaseScene): + + node_moved = pyqtSignal(str, float, float) + + def __init__(self, parent=None): + """ + Create scene of the graph + + :param parent: [Optional, default=None] Parent view + """ + super().__init__(parent) + + self.lastDragPos = QPoint() + self.setItemIndexMethod(QGraphicsScene.NoIndex) + + # list of nodes in scene + self.nodes = dict() + # axis of the scene for debug purpose + # self.addLine(-100, 0, 100, 0) + # self.addLine(0, -100, 0, 100) + self.node_hovered.connect(self.display_path_to) + + # list of nodes in scene + self.nodes = dict() + self.edges = dict() + self.busy = None + 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) + + @staticmethod + def _init_layout(nx_graph): + """ + Init the data of the layout + :param MultiGraph nx_graph: + """ + data = {} + INF = len(nx_graph.nodes()) * len(nx_graph.nodes()) + + for node in nx_graph.nodes(): + data[node] = { + 'theta': None, + 'scenter': INF, + 'nchild': 0, + 'sparent': None, + 'stsize': 0.0, + 'span': 0.0 + } + return data + + @staticmethod + def _set_nstep_to_center(nx_graph, data, current): + """ + Set the number of steps to the center + :param networkx.MultiGraph nx_graph: the graph + :param dict data: the data of the layout + """ + queue = [current] + while queue: + n = queue.pop() + nsteps = data[n]['scenter'] + 1 + for edge in networkx.edges(nx_graph, n): + next_node = edge[0] if edge[0] is not n else edge[1] + if data[next_node]['sparent']: + continue + if nsteps < data[next_node]['scenter']: + data[next_node]['scenter'] = nsteps + data[next_node]['sparent'] = n + data[n]['nchild'] += 1 + queue.append(next_node) + + @staticmethod + def _set_parent_nodes(nx_graph, data, center): + """ + Set the parent of each node + :param networkx.MultiGraph nx_graph: the graph + :param dict data: the data of the layout + :param str center: the id of the node at the center + """ + unset = data[center]['scenter'] + data[center]['scenter'] = 0 + data[center]['sparent'] = None + + logging.debug("Parent node of {0}".format(center)) + ExplorerScene._set_nstep_to_center(nx_graph, data, center) + for node in nx_graph.nodes(): + if data[node]['scenter'] == unset: + return -1 + return max([n['scenter'] for n in data.values()]) + + @staticmethod + def _set_subtree_size(nx_graph, data): + """ + Compute the subtree size of each node, which is the + number of leaves in subtree rooted to the node + :param networkx.MultiGraph nx_graph: the graph + :param dict data: + """ + for node in nx_graph.nodes(): + if data[node]['nchild'] > 0: + continue + data[node]['stsize'] += 1 + parent = data[node]['sparent'] + while parent: + data[parent]['stsize'] += 1 + parent = data[parent]['sparent'] + + @staticmethod + def _set_subtree_spans(nx_graph, data, current): + """ + Compute the subtree spans of each node + :param networkx.MultiGraph nx_graph: the graph + :param dict data: the data of the layout + :param str current: the current node which we compute the subtree + """ + ratio = data[current]['span'] / data[current]['stsize'] + for edge in nx_graph.edges(current): + next_node = edge[0] if edge[0] != current else edge[1] + if data[next_node]['sparent'] != current: + continue + if data[next_node]['span'] != 0.0: + continue + + data[next_node]['span'] = ratio * data[next_node]['stsize'] + if data[next_node]['nchild'] > 0: + ExplorerScene._set_subtree_spans(nx_graph, data, next_node) + + @staticmethod + def _set_positions(nx_graph, data, current): + """ + Compute the polar positions of each node + :param networkx.MultiDiGraph nx_graph: the graph + :param dict data: the data of the layout + :param str current: the current node which we compute the subtree + """ + if not data[current]['sparent']: + theta = 0 + else: + theta = data[current]['theta'] - data[current]['span'] / 2 + + for edge in nx_graph.edges(current): + next_node = edge[0] if edge[0] != current else edge[1] + if data[next_node]['sparent'] != current: + continue + if data[next_node]['theta']: + continue + + data[next_node]['theta'] = theta + data[next_node]['span'] / 2.0 + theta += data[next_node]['span'] + if data[next_node]['nchild'] > 0: + ExplorerScene._set_positions(nx_graph, data, next_node) + + @staticmethod + def twopi_layout(nx_graph, center=None): + """ + Render the twopi layout. Ported from C code available at + https://github.com/ellson/graphviz/blob/master/lib/twopigen/circle.c + + :param networkx.MultiDiGraph nx_graph: the networkx graph + :param str center: the centered node + :return: + """ + if len(nx_graph.nodes()) == 0: + return {} + + if len(nx_graph.nodes()) == 1: + return {nx_graph.nodes()[0]: (0, 0)} + nx_graph = nx_graph.to_undirected() + + data = ExplorerScene._init_layout(nx_graph) + if not center: + center = networkx.center(nx_graph)[0] + ExplorerScene._set_parent_nodes(nx_graph, data, center) + ExplorerScene._set_subtree_size(nx_graph, data) + data[center]['span'] = 2 * math.pi + ExplorerScene._set_subtree_spans(nx_graph, data, center) + data[center]['theta'] = 0.0 + ExplorerScene._set_positions(nx_graph, data, center) + + distances = networkx.shortest_path_length(nx_graph, center) + nx_pos = {} + for node in nx_graph.nodes(): + hyp = distances[node] + 1 + theta = data[node]['theta'] + nx_pos[node] = (hyp * math.cos(theta) * 100, hyp * math.sin(theta) * 100) + return nx_pos + + def clear(self): + """ + clear the scene + """ + for node in self.nodes.values(): + if node.timeline: + node.timeline.stop() + + self.nodes.clear() + self.edges.clear() + super().clear() + + def update_current_identity(self, identity_pubkey): + """ + Update the current identity loaded + + :param str identity_pubkey: + """ + for node in self.nodes.values(): + node.stop_loading_animation() + + if identity_pubkey in self.nodes: + self.nodes[identity_pubkey].start_loading_animation() + + 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.identity = identity + self.nx_graph = nx_graph.copy() + + graph_pos = ExplorerScene.twopi_layout(nx_graph, center=identity.pubkey) + if len(nx_graph.nodes()) > 0: + distances = networkx.shortest_path_length(nx_graph.to_undirected(), identity.pubkey) + else: + distances = {} + + # create networkx graph + for nx_node in nx_graph.nodes(data=True): + if nx_node[0] in self.nodes: + v = self.nodes[nx_node[0]] + v.move_to(graph_pos) + else: + center_pos = None + if len(nx_graph.edges(nx_node[0])) > 0: + for edge in nx_graph.edges(nx_node[0]): + neighbour = edge[0] if edge[0] != nx_node[0] else edge[1] + if neighbour in self.nodes: + center_pos = self.nodes[neighbour].pos() + break + if not center_pos: + if identity.pubkey in self.nodes: + center_pos = self.nodes[identity.pubkey].pos() + else: + center_pos = QPoint(0, 0) + + v = ExplorerNode(nx_node, center_pos, graph_pos, distances[nx_node[0]], dist_max) + self.addItem(v) + self.nodes[nx_node[0]] = v + + for edge in nx_graph.edges(data=True): + edge[2]["confirmation_text"] = "" + if (edge[0], edge[1]) not in self.edges and (edge[1], edge[0]) not in self.edges: + distance = max(self.nodes[edge[0]].steps, self.nodes[edge[1]].steps) + explorer_edge = ExplorerEdge(edge[0], edge[1], edge[2], graph_pos, distance, dist_max) + self.node_moved.connect(explorer_edge.move_source_point) + self.node_moved.connect(explorer_edge.move_destination_point) + self.addItem(explorer_edge) + self.edges[(edge[0], edge[1])] = explorer_edge + + self.update() + + def display_path_to(self, node_id): + if node_id != self.identity.pubkey: + for edge in self.edges.values(): + edge.neutralize() + + for node in self.nodes.values(): + node.neutralize() + try: + path = networkx.shortest_path(self.nx_graph.to_undirected(), self.identity.pubkey, node_id) + + for node, next_node in zip(path[:-1], path[1:]): + if (node, next_node) in self.edges: + edge = self.edges[(node, next_node)] + elif (next_node, node) in self.edges: + edge = self.edges[(next_node, node)] + if edge: + edge.highlight() + self.nodes[node].highlight() + self.nodes[next_node].highlight() + logging.debug("Update edge between {0} and {1}".format(node, next_node)) + except (networkx.exception.NetworkXError, networkx.exception.NetworkXNoPath) as e: + logging.debug(str(e)) diff --git a/src/sakia/gui/views/scenes/wot_scene.py b/src/sakia/gui/views/scenes/wot_scene.py new file mode 100644 index 0000000000000000000000000000000000000000..e1e187a17a8938e4cfb226d577f3b7ec39cbbfd6 --- /dev/null +++ b/src/sakia/gui/views/scenes/wot_scene.py @@ -0,0 +1,171 @@ +import networkx +from PyQt5.QtCore import QPoint, pyqtSignal +from PyQt5.QtWidgets import QGraphicsScene + +from sakia.gui.views.edges import WotEdge +from sakia.gui.views.nodes import WotNode + +from .base_scene import BaseScene + + +class WotScene(BaseScene): + def __init__(self, parent=None): + """ + Create scene of the graph + + :param parent: [Optional, default=None] Parent view + """ + super().__init__(parent) + + self.lastDragPos = QPoint() + self.setItemIndexMethod(QGraphicsScene.NoIndex) + + # list of nodes in scene + self.nodes = dict() + # axis of the scene for debug purpose + # self.addLine(-100, 0, 100, 0) + # self.addLine(0, -100, 0, 100) + + @staticmethod + def certified_partial_layout(nx_graph, center, scale=1): + """ + Method to generate a partial wot with certifiers layout + :param networkx.MultiDiGraph nx_graph: graph of the wot + :param center: the centered node + :param scale: a scale + :return: the positions of the nodes + """ + certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == center] + certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == center] + + certified = [n for n in nx_graph.nodes(data=True) if n[0] in certified_edge] + + pos = {center: (0, max(len(certified_edge), + len(certifier_edge))/2*0.12*scale)} + + y = 0 + x = 1 * scale + # sort by text + sort_certified = sorted(certified, key=lambda node_: node_[1]['text'].lower()) + # add nodes and arcs + for n in sort_certified: + y += 0.25 * scale + pos[n[0]] = (x, y) + return pos + + @staticmethod + def certifiers_partial_layout(nx_graph, center, scale=1): + """ + Method to generate a partial wot with certifiers layout + :param networkx.MultiDiGraph nx_graph: graph of the wot + :param center: the centered node + :param scale: a scale + :return: the positions of the nodes + """ + certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == center] + certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == center] + + certifier = [n for n in nx_graph.nodes(data=True) if n[0] in certifier_edge] + + pos = {center: (0, max(len(certified_edge), + len(certifier_edge))/2*0.12*scale)} + + y = 0 + x = -1 * scale + # sort by text + sort_certifier = sorted(certifier, key=lambda node_: node_[1]['text'].lower()) + # add nodes and arcs + for n in sort_certifier: + y += 0.25 * scale + pos[n[0]] = (x, y) + + return pos + + @staticmethod + def certified_partial_layout(nx_graph, center, scale=1): + """ + Method to generate a partial wot with certifiers layout + :param networkx.MultiDiGraph nx_graph: graph of the wot + :param center: the centered node + :param scale: a scale + :return: the positions of the nodes + """ + certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == center] + certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == center] + + certified = [n for n in nx_graph.nodes(data=True) if n[0] in certified_edge] + + pos = {center: (0, max(len(certified_edge), + len(certifier_edge))/2*0.12*scale)} + + y = 0 + x = 1 * scale + # sort by text + sort_certified = sorted(certified, key=lambda node_: node_[1]['text'].lower()) + # add nodes and arcs + for n in sort_certified: + y += 0.25 * scale + pos[n[0]] = (x, y) + return pos + + @staticmethod + def path_partial_layout(nx_graph, path, scale=1): + """ + + :param networkx.MultiDiGraph nx_graph: The graph to show + :param list path: + :param int scale: + :return: + """ + destination = path[-1] + certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == destination] + certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == destination] + + x = 0 + y = max(len(certified_edge), len(certifier_edge))/2*0.12*scale + pos = {destination: (x, y)} + + for node in reversed(path[:-1]): + y -= 100 + pos[node] = (x, y) + return pos + + def update_wot(self, nx_graph, identity): + """ + draw community graph + + :param networkx.MultiDiGraph nx_graph: graph to draw + :param sakia.core.registry.Identity identity: the wot of the identity + """ + # clear scene + self.clear() + certifiers_graph_pos = WotScene.certifiers_partial_layout(nx_graph, identity.pubkey, scale=200) + certified_graph_pos = WotScene.certified_partial_layout(nx_graph, identity.pubkey, scale=200) + + # create networkx graph + for node in nx_graph.nodes(data=True): + if node[0] in certifiers_graph_pos: + v = WotNode(node, certifiers_graph_pos) + self.addItem(v) + if node[0] in certified_graph_pos: + v = WotNode(node, certified_graph_pos) + self.addItem(v) + + for edge in nx_graph.edges(data=True): + if edge[0] in certifiers_graph_pos and edge[1] == identity.pubkey: + self.addItem(WotEdge(edge[0], edge[1], edge[2], certifiers_graph_pos)) + if edge[0] == identity.pubkey and edge[1] in certified_graph_pos: + self.addItem(WotEdge(edge[0], edge[1], edge[2], certified_graph_pos)) + + self.update() + + def update_path(self, nx_graph, path): + path_graph_pos = WotScene.path_partial_layout(nx_graph, path, scale=200) + nodes_path = [n for n in nx_graph.nodes(data=True) if n[0] in path[:-1]] + for node in nodes_path: + v = WotNode(node, path_graph_pos) + self.addItem(v) + + for edge in nx_graph.edges(data=True): + if edge[0] in path_graph_pos and edge[1] in path_graph_pos: + self.addItem(WotEdge(edge[0], edge[1], edge[2], path_graph_pos)) diff --git a/src/sakia/gui/views/wot.py b/src/sakia/gui/views/wot.py index b17b978cf88230b4767c12b25334c7e44a257036..c4e874e971f68cad026eacfe17d819c1c3d657a5 100644 --- a/src/sakia/gui/views/wot.py +++ b/src/sakia/gui/views/wot.py @@ -1,10 +1,12 @@ import networkx -from PyQt5.QtGui import QPainter, QWheelEvent from PyQt5.QtCore import Qt, QPoint, pyqtSignal +from PyQt5.QtGui import QPainter, QWheelEvent from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene -from .node import Node -from .arc import Arc +from .edges import WotEdge +from .nodes import WotNode +from .scenes import WotScene + class WotView(QGraphicsView): def __init__(self, parent=None): @@ -15,7 +17,7 @@ class WotView(QGraphicsView): """ super(WotView, self).__init__(parent) - self.setScene(Scene(self)) + self.setScene(WotScene(self)) self.setCacheMode(QGraphicsView.CacheBackground) self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate) @@ -42,171 +44,4 @@ class WotView(QGraphicsView): # 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_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) - - # list of nodes in scene - self.nodes = dict() - # axis of the scene for debug purpose - # self.addLine(-100, 0, 100, 0) - # self.addLine(0, -100, 0, 100) - - def certified_partial_layout(self, nx_graph, center, scale=1): - """ - Method to generate a partial wot with certifiers layout - :param networkx.MultiDiGraph nx_graph: graph of the wot - :param center: the centered node - :param scale: a scale - :return: the positions of the nodes - """ - certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == center] - certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == center] - - certified = [n for n in nx_graph.nodes(data=True) if n[0] in certified_edge] - - pos = {center: (0, max(len(certified_edge), - len(certifier_edge))/2*0.12*scale)} - - y = 0 - x = 1 * scale - # sort by text - sort_certified = sorted(certified, key=lambda node_: node_[1]['text'].lower()) - # add nodes and arcs - for n in sort_certified: - y += 0.25 * scale - pos[n[0]] = (x, y) - return pos - - def certifiers_partial_layout(self, nx_graph, center, scale=1): - """ - Method to generate a partial wot with certifiers layout - :param networkx.MultiDiGraph nx_graph: graph of the wot - :param center: the centered node - :param scale: a scale - :return: the positions of the nodes - """ - certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == center] - certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == center] - - certifier = [n for n in nx_graph.nodes(data=True) if n[0] in certifier_edge] - - pos = {center: (0, max(len(certified_edge), - len(certifier_edge))/2*0.12*scale)} - - y = 0 - x = -1 * scale - # sort by text - sort_certifier = sorted(certifier, key=lambda node_: node_[1]['text'].lower()) - # add nodes and arcs - for n in sort_certifier: - y += 0.25 * scale - pos[n[0]] = (x, y) - - return pos - - def certified_partial_layout(self, nx_graph, center, scale=1): - """ - Method to generate a partial wot with certifiers layout - :param networkx.MultiDiGraph nx_graph: graph of the wot - :param center: the centered node - :param scale: a scale - :return: the positions of the nodes - """ - certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == center] - certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == center] - - certified = [n for n in nx_graph.nodes(data=True) if n[0] in certified_edge] - - pos = {center: (0, max(len(certified_edge), - len(certifier_edge))/2*0.12*scale)} - - y = 0 - x = 1 * scale - # sort by text - sort_certified = sorted(certified, key=lambda node_: node_[1]['text'].lower()) - # add nodes and arcs - for n in sort_certified: - y += 0.25 * scale - pos[n[0]] = (x, y) - return pos - - def path_partial_layout(self, nx_graph, path, scale=1): - """ - - :param networkx.Graph nx_graph: The graph to show - :param list path: - :param int scale: - :return: - """ - destination = path[-1] - certifier_edge = [edge[0] for edge in nx_graph.in_edges() if edge[1] == destination] - certified_edge = [edge[1] for edge in nx_graph.out_edges() if edge[0] == destination] - - x = 0 - y = max(len(certified_edge), len(certifier_edge))/2*0.12*scale - pos = {destination: (x, y)} - - for node in reversed(path[:-1]): - y -= 100 - pos[node] = (x, y) - return pos - - def update_wot(self, nx_graph, identity): - """ - draw community graph - - :param networkx.Graph nx_graph: graph to draw - :param sakia.core.registry.Identity identity: the wot of the identity - """ - # clear scene - self.clear() - certifiers_graph_pos = self.certifiers_partial_layout(nx_graph, identity.pubkey, scale=200) - certified_graph_pos = self.certified_partial_layout(nx_graph, identity.pubkey, scale=200) - - # create networkx graph - for node in nx_graph.nodes(data=True): - if node[0] in certifiers_graph_pos: - v = Node(node, certifiers_graph_pos) - self.addItem(v) - if node[0] in certified_graph_pos: - v = Node(node, certified_graph_pos) - self.addItem(v) - - for edge in nx_graph.edges(data=True): - if edge[0] in certifiers_graph_pos and edge[1] == identity.pubkey: - self.addItem(Arc(edge[0], edge[1], edge[2], certifiers_graph_pos)) - if edge[0] == identity.pubkey and edge[1] in certified_graph_pos: - self.addItem(Arc(edge[0], edge[1], edge[2], certified_graph_pos)) - - self.update() - - def update_path(self, nx_graph, path): - path_graph_pos = self.path_partial_layout(nx_graph, path, scale=200) - nodes_path = [n for n in nx_graph.nodes(data=True) if n[0] in path[:-1]] - for node in nodes_path: - v = Node(node, path_graph_pos) - self.addItem(v) - - for edge in nx_graph.edges(data=True): - if edge[0] in path_graph_pos and edge[1] in path_graph_pos: - self.addItem(Arc(edge[0], edge[1], edge[2], path_graph_pos)) + super().wheelEvent(event) diff --git a/src/sakia/gui/widgets/search_user.py b/src/sakia/gui/widgets/search_user.py new file mode 100644 index 0000000000000000000000000000000000000000..94114755631209cefe8ea8551beecb72cc4d0a59 --- /dev/null +++ b/src/sakia/gui/widgets/search_user.py @@ -0,0 +1,106 @@ +import logging + +from PyQt5.QtCore import QEvent, pyqtSignal, QT_TRANSLATE_NOOP +from PyQt5.QtWidgets import QComboBox, QWidget + +from ucoinpy.api import bma + +from ...tools.decorators import asyncify +from ...tools.exceptions import NoPeerAvailable +from ...core.registry import BlockchainState, Identity +from ...gen_resources.search_user_view_uic import Ui_SearchUserWidget + + +class SearchUserWidget(QWidget, Ui_SearchUserWidget): + _search_placeholder = QT_TRANSLATE_NOOP("SearchUserWidget", "Research a pubkey, an uid...") + + identity_selected = pyqtSignal(Identity) + reset = pyqtSignal() + + def __init__(self, parent): + """ + :param sakia.core.app.Application app: Application instance + """ + # construct from qtDesigner + super().__init__(parent) + self.setupUi(self) + # Default text when combo lineEdit is empty + self.combobox_search.lineEdit().setPlaceholderText(self.tr(SearchUserWidget._search_placeholder)) + # Â add combobox events + self.combobox_search.lineEdit().returnPressed.connect(self.search) + #Â To fix a recall of the same item with different case, + #Â the edited text is not added in the item list + self.combobox_search.setInsertPolicy(QComboBox.NoInsert) + self.combobox_search.activated.connect(self.select_node) + self.button_reset.clicked.connect(self.reset) + self.nodes = list() + self.community = None + self.account = None + self.app = None + + def init(self, app): + """ + Initialize the widget + :param sakia.core.Application app: the application + """ + self.app = app + + def change_account(self, account): + self.account = account + + def change_community(self, community): + self.community = community + + @asyncify + async def search(self): + """ + Search nodes when return is pressed in combobox lineEdit + """ + text = self.combobox_search.lineEdit().text() + + if len(text) < 2: + return False + try: + response = await self.community.bma_access.future_request(bma.wot.Lookup, {'search': text}) + + nodes = {} + for identity in response['results']: + nodes[identity['pubkey']] = identity['uids'][0]['uid'] + + if nodes: + self.nodes = list() + self.blockSignals(True) + self.combobox_search.clear() + self.combobox_search.lineEdit().setText(text) + for pubkey, uid in nodes.items(): + self.nodes.append({'pubkey': pubkey, 'uid': uid}) + self.combobox_search.addItem(uid) + self.blockSignals(False) + self.combobox_search.showPopup() + 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.identity_selected.emit( + self.app.identities_registry.from_handled_data( + metadata['text'], + metadata['id'], + None, + BlockchainState.VALIDATED, + self.community + ) + ) + + def retranslateUi(self, widget): + """ + Retranslate missing widgets from generated code + """ + self.combobox_search.lineEdit().setPlaceholderText(self.tr(SearchUserWidget._search_placeholder)) + super().retranslateUi(self) diff --git a/src/sakia/tests/unit/core/graph/test_base_graph.py b/src/sakia/tests/unit/core/graph/test_base_graph.py index b06c202b4c521b3b0aed7019d8cd1ddfb2fec5c5..7446f71f75ea0e8e8ba984dae1fd0ff5b6f38b32 100644 --- a/src/sakia/tests/unit/core/graph/test_base_graph.py +++ b/src/sakia/tests/unit/core/graph/test_base_graph.py @@ -5,7 +5,7 @@ from asynctest.mock import Mock, CoroutineMock, patch from PyQt5.QtCore import QLocale from sakia.tests import QuamashTest from sakia.core.graph import BaseGraph -from sakia.core.graph.constants import ArcStatus, NodeStatus +from sakia.core.graph.constants import EdgeStatus, NodeStatus class TestBaseGraph(unittest.TestCase, QuamashTest): @@ -40,9 +40,9 @@ class TestBaseGraph(unittest.TestCase, QuamashTest): base_graph = BaseGraph(app, community) async def exec_test(): - self.assertEquals((await base_graph.arc_status(48000)), ArcStatus.WEAK) - self.assertEquals((await base_graph.arc_status(49500)), ArcStatus.STRONG) - self.assertEquals((await base_graph.arc_status(49200)), ArcStatus.WEAK) + self.assertEquals((await base_graph.arc_status(48000)), EdgeStatus.WEAK) + self.assertEquals((await base_graph.arc_status(49500)), EdgeStatus.STRONG) + self.assertEquals((await base_graph.arc_status(49200)), EdgeStatus.WEAK) self.lp.run_until_complete(exec_test()) @@ -135,12 +135,12 @@ class TestBaseGraph(unittest.TestCase, QuamashTest): arc_from_first = [e for e in edges if e[0] == self.first_identity.pubkey][0] self.assertEqual(arc_from_first[1], self.account_identity.pubkey) - self.assertEqual(arc_from_first[2]['status'], ArcStatus.WEAK) + self.assertEqual(arc_from_first[2]['status'], EdgeStatus.WEAK) self.assertEqual(arc_from_first[2]['cert_time'], certifications[0]['cert_time']) arc_from_second = [e for e in edges if e[0] == self.second_identity.pubkey][0] self.assertEqual(arc_from_second[1], self.account_identity.pubkey) - self.assertEqual(arc_from_second[2]['status'], ArcStatus.STRONG) + self.assertEqual(arc_from_second[2]['status'], EdgeStatus.STRONG) self.assertEqual(arc_from_second[2]['cert_time'], certifications[1]['cert_time']) self.lp.run_until_complete(exec_test()) @@ -200,8 +200,3 @@ class TestBaseGraph(unittest.TestCase, QuamashTest): self.assertEqual(account_node[1]['status'], NodeStatus.HIGHLIGHTED) self.assertEqual(account_node[1]['text'], self.account_identity.uid) self.assertEqual(account_node[1]['tooltip'], self.account_identity.pubkey) - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/core/graph/test_explorer_graph.py b/src/sakia/tests/unit/core/graph/test_explorer_graph.py index 457e4fed6c76b60fbd87a2da7c225f29c99f4316..035faab3b06da16e604c48779e56c0a8882ad295 100644 --- a/src/sakia/tests/unit/core/graph/test_explorer_graph.py +++ b/src/sakia/tests/unit/core/graph/test_explorer_graph.py @@ -72,7 +72,7 @@ class TestExplorerGraph(unittest.TestCase, QuamashTest): } ]) - self.idC.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certifierd_by', + self.idC.unique_valid_certified_by = CoroutineMock(spec='core.registry.Identity.certified_by', return_value=[ { 'cert_time': 49100, @@ -123,7 +123,7 @@ class TestExplorerGraph(unittest.TestCase, QuamashTest): @patch('sakia.core.Application') @patch('sakia.core.Community') @patch('time.time', Mock(return_value=50000)) - def test_explore_full(self, app, community): + def test_explore_full_from_center(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} @@ -137,6 +137,23 @@ class TestExplorerGraph(unittest.TestCase, QuamashTest): 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_full_from_extremity(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.idA, 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)) @@ -178,6 +195,10 @@ class TestExplorerGraph(unittest.TestCase, QuamashTest): 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()) diff --git a/src/sakia/tests/unit/core/graph/test_wot_graph.py b/src/sakia/tests/unit/core/graph/test_wot_graph.py index bdfa851eb820050339acf252913e14b09276d24e..262f75a1c2fee5c82ca14107e478ad3a443765fb 100644 --- a/src/sakia/tests/unit/core/graph/test_wot_graph.py +++ b/src/sakia/tests/unit/core/graph/test_wot_graph.py @@ -5,7 +5,7 @@ from asynctest.mock import Mock, CoroutineMock, patch from PyQt5.QtCore import QLocale from sakia.tests import QuamashTest from sakia.core.graph import WoTGraph -from sakia.core.graph.constants import ArcStatus, NodeStatus +from sakia.core.graph.constants import EdgeStatus, NodeStatus class TestWotGraph(unittest.TestCase, QuamashTest): diff --git a/src/sakia/tests/unit/core/test_account.py b/src/sakia/tests/unit/core/test_account.py index 4648cd4666b69bb67a172d010e7709222e2e035c..8fae00475d12656daa12956d4d36599132c33816 100644 --- a/src/sakia/tests/unit/core/test_account.py +++ b/src/sakia/tests/unit/core/test_account.py @@ -1,12 +1,13 @@ +import sys import unittest -from unittest.mock import patch, Mock -from asynctest import CoroutineMock +import asyncio +import quamash +import logging from PyQt5.QtCore import QLocale from sakia.core.registry.identities import IdentitiesRegistry from sakia.core import Account from sakia.tests import QuamashTest -from sakia.tests.mocks.bma import nice_blockchain -from ucoinpy.api import bma + class TestAccount(unittest.TestCase, QuamashTest): def setUp(self): @@ -27,95 +28,3 @@ class TestAccount(unittest.TestCase, QuamashTest): self.assertEqual(account.pubkey, account_from_json.pubkey) self.assertEqual(len(account.communities), len(account_from_json.communities)) self.assertEqual(len(account.wallets), len(account.wallets)) - - @patch('sakia.core.Application') - @patch('sakia.core.Community') - @patch('sakia.core.net.api.bma.access.BmaAccess') - def test_check_register_success(self, app, community, bma_access): - account = Account("test_salt", "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ", - "john", [], [], [], self.identities_registry) - - def mock_simple_request(request, req_args, get_args={}): - if request is bma.wot.Lookup: - if req_args['search'] == "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ": - return nice_blockchain.bma_lookup_john - elif req_args['search'] == "john": - return nice_blockchain.bma_lookup_john - elif request is bma.wot.CertifiersOf: - if req_args['search'] == "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ": - return nice_blockchain.bma_certifiers_of_john - elif req_args['search'] == "john": - return nice_blockchain.bma_certifiers_of_john - community.bma_access = bma_access - bma_access.simple_request = CoroutineMock(side_effect=mock_simple_request) - - async def exec_test(): - result = await account.check_registered(community) - self.assertEqual(result, (True, "john", - "john")) - - self.lp.run_until_complete(exec_test()) - - @patch('sakia.core.Application') - @patch('sakia.core.Community') - @patch('sakia.core.net.api.bma.access.BmaAccess') - def test_check_register_pubkey_failure(self, app, community, bma_access): - account = Account("test_salt", "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ", - "john", [], [], [], self.identities_registry) - - def mock_simple_request(request, req_args, get_args={}): - if request is bma.wot.Lookup: - if req_args['search'] == "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ": - raise ValueError('404 not found') - elif req_args['search'] == "john": - result = nice_blockchain.bma_lookup_john - result['pubkey'] = "FADxcH5LmXGmGFgdixSes6nWnC4Vb4pRUBYT81zQRhjn" - return result - elif request is bma.wot.CertifiersOf: - if req_args['search'] == "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ": - raise ValueError('404 not found') - elif req_args['search'] == "john": - result = nice_blockchain.bma_certifiers_of_john - result['pubkey'] = "FADxcH5LmXGmGFgdixSes6nWnC4Vb4pRUBYT81zQRhjn" - return result - community.bma_access = bma_access - bma_access.simple_request = CoroutineMock(side_effect=mock_simple_request) - - async def exec_test(): - result = await account.check_registered(community) - self.assertEqual(result, (False, "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ", - "FADxcH5LmXGmGFgdixSes6nWnC4Vb4pRUBYT81zQRhjn")) - - self.lp.run_until_complete(exec_test()) - - @patch('sakia.core.Application') - @patch('sakia.core.Community') - @patch('sakia.core.net.api.bma.access.BmaAccess') - def test_check_register_uid_failure(self, app, community, bma_access): - account = Account("test_salt", "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ", - "john", [], [], [], self.identities_registry) - - def mock_simple_request(request, req_args, get_args={}): - if request is bma.wot.Lookup: - if req_args['search'] == "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ": - result = nice_blockchain.bma_lookup_john - result['uid'] = "imnotjohn" - return result - elif req_args['search'] == "john": - raise ValueError('404 not found') - elif request is bma.wot.CertifiersOf: - if req_args['search'] == "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ": - result = nice_blockchain.bma_certifiers_of_john - result['uid'] = "imnotjohn" - return result - elif req_args['search'] == "john": - raise ValueError('404 not found') - community.bma_access = bma_access - bma_access.simple_request = CoroutineMock(side_effect=mock_simple_request) - - async def exec_test(): - result = await account.check_registered(community) - self.assertEqual(result, (False, "john", - "imnotjohn")) - - self.lp.run_until_complete(exec_test()) \ No newline at end of file diff --git a/src/sakia/tests/unit/core/test_bma_access.py b/src/sakia/tests/unit/core/test_bma_access.py index 6708d6937410b5c4b17add6a912321631e6f9890..293eb3ddf51a74dbc177c8b33a9825314c7e5380 100644 --- a/src/sakia/tests/unit/core/test_bma_access.py +++ b/src/sakia/tests/unit/core/test_bma_access.py @@ -44,8 +44,3 @@ class TestBmaAccess(unittest.TestCase, QuamashTest): def test_filter_nodes(self): pass#TODO - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/core/test_community.py b/src/sakia/tests/unit/core/test_community.py index 8083564c98fcff928e4eeb27073bbe4116097b2b..acf5c176be84783ec2fabde14674f3cd9e50e9bd 100644 --- a/src/sakia/tests/unit/core/test_community.py +++ b/src/sakia/tests/unit/core/test_community.py @@ -25,9 +25,3 @@ class TestCommunity(unittest.TestCase, QuamashTest): community_from_json = Community.load(json_data) self.assertEqual(community.name, community_from_json.name) self.assertEqual(len(community.network._nodes), len(community_from_json.network._nodes)) - - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/core/test_identities.py b/src/sakia/tests/unit/core/test_identities.py index e81dc9af2aae1996f832f7eca909d50432258ef8..f00d01847f6d5c886022d4e7dd5df5d985875d38 100644 --- a/src/sakia/tests/unit/core/test_identities.py +++ b/src/sakia/tests/unit/core/test_identities.py @@ -35,8 +35,3 @@ class TestIdentity(unittest.TestCase, QuamashTest): community) self.assertEqual(identity, identity_from_data) - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/core/test_identity.py b/src/sakia/tests/unit/core/test_identity.py index ed8c95b83f006a84255f62456073c9b40083c21d..ec7044f771f6e5e28bfffb9bc5eb4c48a5960d2e 100644 --- a/src/sakia/tests/unit/core/test_identity.py +++ b/src/sakia/tests/unit/core/test_identity.py @@ -88,8 +88,3 @@ class TestIdentity(unittest.TestCase, QuamashTest): self.lp.run_until_complete(exec_test()) - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/core/test_wallet.py b/src/sakia/tests/unit/core/test_wallet.py index a3cdcdebaa54a171a0d859185aaedb5d3cb19c39..04253105d291e1a8ee4264cd646690ff2bc76be3 100644 --- a/src/sakia/tests/unit/core/test_wallet.py +++ b/src/sakia/tests/unit/core/test_wallet.py @@ -28,8 +28,3 @@ class TestWallet(unittest.TestCase, QuamashTest): self.assertEqual(wallet.pubkey, wallet_from_json.pubkey) self.assertEqual(wallet.name, wallet_from_json.name) self.assertEqual(wallet._identities_registry, wallet_from_json._identities_registry) - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/core/txhistory/test_txhistory_loading.py b/src/sakia/tests/unit/core/txhistory/test_txhistory_loading.py index 8c106b46eebd4e4bc4fbbf53b67bcb4fa28cd2af..5ec06b989afe3b497e185407bbddb142738c306e 100644 --- a/src/sakia/tests/unit/core/txhistory/test_txhistory_loading.py +++ b/src/sakia/tests/unit/core/txhistory/test_txhistory_loading.py @@ -61,8 +61,3 @@ class TestTxHistory(unittest.TestCase, QuamashTest): dividends_value = sum([ud['amount'] for ud in self.wallet.dividends(self.community)]) self.assertEqual(dividends_value, 15) mock.delete_mock() - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr) - logging.getLogger().setLevel(logging.DEBUG) - unittest.main() diff --git a/src/sakia/tests/unit/gui/views/__init__.py b/src/sakia/tests/unit/gui/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/sakia/tests/unit/gui/views/test_base_edge.py b/src/sakia/tests/unit/gui/views/test_base_edge.py new file mode 100644 index 0000000000000000000000000000000000000000..f05353bd653e425dc05be4f841960b810d3e39f0 --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_base_edge.py @@ -0,0 +1,34 @@ +import unittest +from PyQt5.QtCore import QLocale +from sakia.tests import QuamashTest +from sakia.gui.views.edges.base_edge import BaseEdge +from sakia.core.graph.constants import EdgeStatus + + +class TestBaseEdge(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + def tearDown(self): + self.tearDownQuamash() + + def test_create_edge(self): + metadata = { + 'status': EdgeStatus.STRONG + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + edge = BaseEdge("A", "B", metadata, nx_pos) + self.assertEqual(edge.source, "A") + self.assertEqual(edge.destination, "B") + self.assertEqual(edge.destination_point.x(), 10) + self.assertEqual(edge.destination_point.y(), 20) + self.assertEqual(edge.source_point.x(), 0) + self.assertEqual(edge.source_point.y(), 5) + self.assertEqual(edge.status, EdgeStatus.STRONG) + + self.lp.run_until_complete(exec_test()) \ No newline at end of file diff --git a/src/sakia/tests/unit/gui/views/test_base_node.py b/src/sakia/tests/unit/gui/views/test_base_node.py new file mode 100644 index 0000000000000000000000000000000000000000..4162afe55fb9dcd61b44a60b675d70e154e84921 --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_base_node.py @@ -0,0 +1,37 @@ +import unittest +from PyQt5.QtCore import QLocale +from sakia.tests import QuamashTest +from sakia.gui.views.nodes.base_node import BaseNode +from sakia.core.graph.constants import NodeStatus + + +class TestBaseNode(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + def tearDown(self): + self.tearDownQuamash() + + def test_create_edge(self): + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "UserA", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = BaseNode(("A", metadata), nx_pos) + self.assertEqual(node.id, "A") + self.assertEqual(node.metadata['status'], NodeStatus.NEUTRAL) + self.assertEqual(node.x(), 0) + self.assertEqual(node.y(), 5) + self.assertEqual(node.status_wallet, False) + self.assertEqual(node.status_member, True) + self.assertEqual(node.text, "UserA") + self.assertEqual(node.toolTip(), "TestTooltip") + + self.lp.run_until_complete(exec_test()) \ No newline at end of file diff --git a/src/sakia/tests/unit/gui/views/test_explorer_edge.py b/src/sakia/tests/unit/gui/views/test_explorer_edge.py new file mode 100644 index 0000000000000000000000000000000000000000..5732190cbca2a56b3f0e6cfa14cf4eb520f3ccdd --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_explorer_edge.py @@ -0,0 +1,76 @@ +import unittest +from unittest.mock import patch +from PyQt5.QtCore import QLocale +from sakia.tests import QuamashTest +from sakia.gui.views.edges import ExplorerEdge +from sakia.core.graph.constants import EdgeStatus + + +class TestExplorerEdge(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + def tearDown(self): + self.tearDownQuamash() + + def test_create_wot_edge(self): + metadata = { + 'status': EdgeStatus.STRONG, + 'confirmation_text': "0/6" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + edge = ExplorerEdge("A", "B", metadata, nx_pos, 0, 0) + self.assertEqual(edge.source, "A") + self.assertEqual(edge.destination, "B") + self.assertAlmostEqual(edge.destination_point.x(), 10.0, delta=5) + self.assertAlmostEqual(edge.destination_point.y(), 20.0, delta=5) + self.assertAlmostEqual(edge.source_point.x(), 10.0, delta=5) + self.assertAlmostEqual(edge.source_point.y(), 20.0, delta=5) + self.assertEqual(edge.status, EdgeStatus.STRONG) + + self.lp.run_until_complete(exec_test()) + + @patch('PyQt5.QtGui.QPainter') + @patch('PyQt5.QtWidgets.QWidget') + def test_paint(self, painter, widget): + metadata = { + 'status': EdgeStatus.STRONG, + 'confirmation_text': "0/6" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + + async def exec_test(): + edge = ExplorerEdge("A", "B", metadata, nx_pos, 0, 1) + edge.paint(painter, 0, widget) + + self.lp.run_until_complete(exec_test()) + + @patch('PyQt5.QtGui.QPainter') + @patch('PyQt5.QtWidgets.QWidget') + def test_bounding_rect(self, painter, widget): + metadata = { + 'status': EdgeStatus.STRONG, + 'confirmation_text': "0/6" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + + async def exec_test(): + edge = ExplorerEdge("A", "B", metadata, nx_pos, 0, 0) + bounding_rect = edge.boundingRect() + self.assertAlmostEqual(bounding_rect.x(), 7.0, delta=5) + self.assertAlmostEqual(bounding_rect.y(), 17.0, delta=5) + self.assertAlmostEqual(bounding_rect.width(), 6.0, delta=5) + self.assertAlmostEqual(bounding_rect.height(), 6.0, delta=5) + + self.lp.run_until_complete(exec_test()) diff --git a/src/sakia/tests/unit/gui/views/test_explorer_node.py b/src/sakia/tests/unit/gui/views/test_explorer_node.py new file mode 100644 index 0000000000000000000000000000000000000000..cd54b19f534f54e2931d1f092d872f1d1f3bc993 --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_explorer_node.py @@ -0,0 +1,80 @@ +import unittest +from unittest.mock import patch +from PyQt5.QtCore import QLocale, QPointF +from PyQt5.QtGui import QPainter +from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget +from sakia.tests import QuamashTest +from sakia.gui.views.nodes import ExplorerNode +from sakia.core.graph.constants import NodeStatus + + +class TestExplorerNode(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + def tearDown(self): + self.tearDownQuamash() + + def test_create_explorer_node(self): + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "UserA", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = ExplorerNode(("A", metadata), QPointF(0, 0), nx_pos, 0, 1) + self.assertEqual(node.id, "A") + self.assertEqual(node.metadata['status'], NodeStatus.NEUTRAL) + self.assertEqual(node.x(), 0) + self.assertEqual(node.y(), 0) + self.assertEqual(node.status_wallet, False) + self.assertEqual(node.status_member, True) + self.assertEqual(node.text, "UserA") + self.assertEqual(node.toolTip(), "TestTooltip") + + self.lp.run_until_complete(exec_test()) + + def test_paint(self): + painter = QPainter() + widget = QWidget() + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "UserA", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = ExplorerNode(("A", metadata), QPointF(0, 0), nx_pos, 0, 1) + node.paint(painter, QStyleOptionGraphicsItem(), widget) + + self.lp.run_until_complete(exec_test()) + + @patch('PyQt5.QtGui.QPainter', spec=QPainter) + @patch('PyQt5.QtWidgets.QWidget') + def test_bounding_rect(self, painter, widget): + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "A", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = ExplorerNode(("A", metadata), QPointF(0, 0), nx_pos, 0, 1) + bounding_rect = node.boundingRect() + self.assertAlmostEqual(bounding_rect.x(), -0.5, delta=5) + self.assertAlmostEqual(bounding_rect.y(), -0.5, delta=5) + self.assertAlmostEqual(bounding_rect.width(), 19.59375, delta=5) + self.assertAlmostEqual(bounding_rect.height(), 37.0, delta=5) + + self.lp.run_until_complete(exec_test()) diff --git a/src/sakia/tests/unit/gui/views/test_explorer_scene.py b/src/sakia/tests/unit/gui/views/test_explorer_scene.py new file mode 100644 index 0000000000000000000000000000000000000000..9585e42c4eca9f210996c4ceb6229402f51c3778 --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_explorer_scene.py @@ -0,0 +1,179 @@ +import unittest +import networkx +import math +from unittest.mock import patch, Mock +from sakia.tests import QuamashTest +from sakia.gui.views.scenes import ExplorerScene +from sakia.core.graph.constants import NodeStatus + + +class TestExplorerScene(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + self.identities_uids = ['A', 'B', 'C', 'D', 'E'] + self.identities_pubkeys = ['pbkA', 'pbkB', 'pbkC', 'pbkD', 'pbkE'] + self.certifications = [('pbkA', 'pbkB'), + ('pbkB', 'pbkC'), + ('pbkD', 'pbkA'), + ('pbkA', 'pbkE')] + # Graph : + # A -> B -> C + # <- D + # -> E + self.identity_status = [NodeStatus.SELECTED, NodeStatus.NEUTRAL, NodeStatus.NEUTRAL, + NodeStatus.OUT, NodeStatus.NEUTRAL] + self.test_graph = networkx.MultiDiGraph().to_undirected() + self.test_graph.add_nodes_from(self.identities_pubkeys) + self.test_graph.add_edges_from(self.certifications) + for index, node in enumerate(self.test_graph.nodes(data=True)): + node[1]['text'] = self.identities_uids[index] + node[1]['tooltip'] = self.identities_pubkeys[index] + node[1]['status'] = self.identity_status[index] + + def tearDown(self): + self.tearDownQuamash() + + def test_init_layout(self): + data_layout = ExplorerScene._init_layout(self.test_graph) + for pubkey in self.identities_pubkeys: + self.assertEqual(data_layout[pubkey]['theta'], None) + self.assertEqual(data_layout[pubkey]['scenter'], 25) + self.assertEqual(data_layout[pubkey]['nchild'], 0) + self.assertEqual(data_layout[pubkey]['sparent'], None) + self.assertEqual(data_layout[pubkey]['stsize'], 0.0) + self.assertEqual(data_layout[pubkey]['span'], 0.0) + + def test_set_parent_nodes(self): + data_layout = ExplorerScene._init_layout(self.test_graph) + ExplorerScene._set_parent_nodes(self.test_graph, data_layout, 'pbkA') + self.assertEqual(data_layout['pbkA']['scenter'], 0) + self.assertEqual(data_layout['pbkB']['scenter'], 1) + self.assertEqual(data_layout['pbkC']['scenter'], 2) + self.assertEqual(data_layout['pbkD']['scenter'], 1) + self.assertEqual(data_layout['pbkE']['scenter'], 1) + + self.assertEqual(data_layout['pbkA']['sparent'], None) + self.assertEqual(data_layout['pbkB']['sparent'], 'pbkA') + self.assertEqual(data_layout['pbkC']['sparent'], 'pbkB') + self.assertEqual(data_layout['pbkD']['sparent'], 'pbkA') + self.assertEqual(data_layout['pbkE']['scenter'], 1) + + self.assertEqual(data_layout['pbkA']['nchild'], 3) + self.assertEqual(data_layout['pbkB']['nchild'], 1) + self.assertEqual(data_layout['pbkC']['nchild'], 0) + self.assertEqual(data_layout['pbkD']['nchild'], 0) + self.assertEqual(data_layout['pbkE']['nchild'], 0) + + def test_set_subtree_size(self): + data_layout = ExplorerScene._init_layout(self.test_graph) + + data_layout['pbkA']['sparent'] = None + data_layout['pbkB']['sparent'] = 'pbkA' + data_layout['pbkC']['sparent'] = 'pbkB' + data_layout['pbkD']['sparent'] = 'pbkA' + data_layout['pbkE']['sparent'] = 'pbkA' + + data_layout['pbkA']['nchild'] = 2 + data_layout['pbkB']['nchild'] = 1 + data_layout['pbkC']['nchild'] = 0 + data_layout['pbkD']['nchild'] = 0 + data_layout['pbkE']['nchild'] = 0 + + ExplorerScene._set_subtree_size(self.test_graph, data_layout) + self.assertAlmostEqual(data_layout['pbkA']['stsize'], 3.0) + self.assertAlmostEqual(data_layout['pbkB']['stsize'], 1.0) + self.assertAlmostEqual(data_layout['pbkC']['stsize'], 1.0) + self.assertAlmostEqual(data_layout['pbkD']['stsize'], 1.0) + self.assertAlmostEqual(data_layout['pbkE']['stsize'], 1.0) + + def test_set_subtree_span(self): + data_layout = ExplorerScene._init_layout(self.test_graph) + + data_layout['pbkA']['sparent'] = None + data_layout['pbkB']['sparent'] = 'pbkA' + data_layout['pbkC']['sparent'] = 'pbkB' + data_layout['pbkD']['sparent'] = 'pbkA' + data_layout['pbkE']['sparent'] = 'pbkA' + + data_layout['pbkA']['nchild'] = 2 + data_layout['pbkB']['nchild'] = 1 + data_layout['pbkC']['nchild'] = 0 + data_layout['pbkD']['nchild'] = 0 + data_layout['pbkE']['nchild'] = 0 + + data_layout['pbkA']['stsize'] = 3.0 + data_layout['pbkB']['stsize'] = 1.0 + data_layout['pbkC']['stsize'] = 1.0 + data_layout['pbkD']['stsize'] = 1.0 + data_layout['pbkE']['stsize'] = 1.0 + + data_layout['pbkA']['span'] = 2 * math.pi + + ExplorerScene._set_subtree_spans(self.test_graph, data_layout, 'pbkA') + self.assertAlmostEqual(data_layout['pbkA']['span'], 2 * math.pi) + self.assertAlmostEqual(data_layout['pbkB']['span'], 2 / 3 * math.pi) + self.assertAlmostEqual(data_layout['pbkC']['span'], 2 / 3 * math.pi) + self.assertAlmostEqual(data_layout['pbkD']['span'], 2 / 3 * math.pi) + self.assertAlmostEqual(data_layout['pbkE']['span'], 2 / 3 * math.pi) + + @patch('networkx.MultiDiGraph') + def test_set_subtree_position(self, mock_graph): + # We mock the edges generator to ensure the order in which they appear + mock_graph.edges = Mock(return_value=self.certifications) + data_layout = {} + + for pubkey in self.identities_pubkeys: + data_layout[pubkey] = { + 'theta': None + } + + data_layout['pbkA']['sparent'] = None + data_layout['pbkB']['sparent'] = 'pbkA' + data_layout['pbkC']['sparent'] = 'pbkB' + data_layout['pbkD']['sparent'] = 'pbkA' + data_layout['pbkE']['sparent'] = 'pbkA' + + data_layout['pbkA']['nchild'] = 2 + data_layout['pbkB']['nchild'] = 1 + data_layout['pbkC']['nchild'] = 0 + data_layout['pbkD']['nchild'] = 0 + data_layout['pbkE']['nchild'] = 0 + + data_layout['pbkA']['span'] = 2 * math.pi + data_layout['pbkB']['span'] = 2 / 3 * math.pi + data_layout['pbkC']['span'] = 2 / 3 * math.pi + data_layout['pbkD']['span'] = 2 / 3 * math.pi + data_layout['pbkE']['span'] = 2 / 3 * math.pi + + data_layout['pbkA']['theta'] = 0.0 + ExplorerScene._set_positions(mock_graph, data_layout, 'pbkA') + self.assertAlmostEqual(data_layout['pbkA']['theta'], 0.0) + self.assertAlmostEqual(data_layout['pbkB']['theta'], 1 / 3 * math.pi) + self.assertAlmostEqual(data_layout['pbkC']['theta'], 1 / 3 * math.pi) + self.assertAlmostEqual(data_layout['pbkD']['theta'], math.pi) + self.assertAlmostEqual(data_layout['pbkE']['theta'], 5 / 3 * math.pi) + + @patch('networkx.MultiDiGraph') + @patch('networkx.MultiGraph') + @patch('networkx.shortest_path_length', return_value={'pbkA': 0, 'pbkB': 1, 'pbkC': 2, 'pbkD': 1, 'pbkE': 1}) + def test_twopi_layout(self, mock_graph, mock_undirected, mock_paths): + # We mock the edges generator to ensure the order in which they appear + mock_graph.edges = Mock(return_value=self.certifications) + mock_graph.nodes = Mock(return_value=self.identities_pubkeys) + mock_graph.to_undirected = Mock(return_value=mock_undirected) + mock_undirected.nodes = Mock(return_value=self.identities_pubkeys) + mock_undirected.edges = Mock(return_value=self.certifications) + + pos = ExplorerScene.twopi_layout(mock_graph, 'pbkA') + + self.assertAlmostEqual(pos['pbkA'][0], 1 * math.cos(0.0) * 100) + self.assertAlmostEqual(pos['pbkB'][0], 2 * math.cos(1 / 3 * math.pi) * 100) + self.assertAlmostEqual(pos['pbkC'][0], 3 * math.cos(1 / 3 * math.pi) * 100) + self.assertAlmostEqual(pos['pbkD'][0], 2 * math.cos(math.pi) * 100) + self.assertAlmostEqual(pos['pbkE'][0], 2 * math.cos(5 / 3 * math.pi) * 100) + + self.assertAlmostEqual(pos['pbkA'][1], 1 * math.sin(0.0) * 100) + self.assertAlmostEqual(pos['pbkB'][1], 2 * math.sin(1 / 3 * math.pi) * 100) + self.assertAlmostEqual(pos['pbkC'][1], 3 * math.sin(1 / 3 * math.pi) * 100) + self.assertAlmostEqual(pos['pbkD'][1], 2 * math.sin(math.pi) * 100) + self.assertAlmostEqual(pos['pbkE'][1], 2 * math.sin(5 / 3 * math.pi) * 100) \ No newline at end of file diff --git a/src/sakia/tests/unit/gui/views/test_wot_edge.py b/src/sakia/tests/unit/gui/views/test_wot_edge.py new file mode 100644 index 0000000000000000000000000000000000000000..6c0632b97e15f9e5eace680549297129cec79ba9 --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_wot_edge.py @@ -0,0 +1,76 @@ +import unittest +from unittest.mock import patch +from PyQt5.QtCore import QLocale +from sakia.tests import QuamashTest +from sakia.gui.views.edges import WotEdge +from sakia.core.graph.constants import EdgeStatus + + +class TestWotEdge(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + def tearDown(self): + self.tearDownQuamash() + + def test_create_wot_edge(self): + metadata = { + 'status': EdgeStatus.STRONG, + 'confirmation_text': "0/6" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + edge = WotEdge("A", "B", metadata, nx_pos) + self.assertEqual(edge.source, "A") + self.assertEqual(edge.destination, "B") + self.assertEqual(edge.destination_point.x(), 10) + self.assertEqual(edge.destination_point.y(), 20) + self.assertEqual(edge.source_point.x(), 0) + self.assertEqual(edge.source_point.y(), 5) + self.assertEqual(edge.status, EdgeStatus.STRONG) + + self.lp.run_until_complete(exec_test()) + + @patch('PyQt5.QtGui.QPainter') + @patch('PyQt5.QtWidgets.QWidget') + def test_paint(self, painter, widget): + metadata = { + 'status': EdgeStatus.STRONG, + 'confirmation_text': "0/6" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + + async def exec_test(): + edge = WotEdge("A", "B", metadata, nx_pos) + edge.paint(painter, 0, widget) + + self.lp.run_until_complete(exec_test()) + + @patch('PyQt5.QtGui.QPainter') + @patch('PyQt5.QtWidgets.QWidget') + def test_bounding_rect(self, painter, widget): + metadata = { + 'status': EdgeStatus.STRONG, + 'confirmation_text': "0/6" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + + async def exec_test(): + edge = WotEdge("A", "B", metadata, nx_pos) + bounding_rect = edge.boundingRect() + self.assertAlmostEqual(bounding_rect.x(), -3.0, delta=5) + self.assertAlmostEqual(bounding_rect.y(), 2.0, delta=5) + self.assertAlmostEqual(bounding_rect.width(), 16.0, delta=5) + self.assertAlmostEqual(bounding_rect.height(), 21.0, delta=5) + + self.lp.run_until_complete(exec_test()) diff --git a/src/sakia/tests/unit/gui/views/test_wot_node.py b/src/sakia/tests/unit/gui/views/test_wot_node.py new file mode 100644 index 0000000000000000000000000000000000000000..a92ba2daa72a6309c08b885178f3a16274b989db --- /dev/null +++ b/src/sakia/tests/unit/gui/views/test_wot_node.py @@ -0,0 +1,80 @@ +import unittest +from unittest.mock import patch +from PyQt5.QtCore import QLocale +from PyQt5.QtGui import QPainter +from PyQt5.QtWidgets import QStyleOptionGraphicsItem, QWidget +from sakia.tests import QuamashTest +from sakia.gui.views.nodes import WotNode +from sakia.core.graph.constants import NodeStatus + + +class TestWotNode(unittest.TestCase, QuamashTest): + def setUp(self): + self.setUpQuamash() + QLocale.setDefault(QLocale("en_GB")) + + def tearDown(self): + self.tearDownQuamash() + + def test_create_wot_node(self): + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "UserA", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = WotNode(("A", metadata), nx_pos) + self.assertEqual(node.id, "A") + self.assertEqual(node.metadata['status'], NodeStatus.NEUTRAL) + self.assertEqual(node.x(), 0) + self.assertEqual(node.y(), 5) + self.assertEqual(node.status_wallet, False) + self.assertEqual(node.status_member, True) + self.assertEqual(node.text, "UserA") + self.assertEqual(node.toolTip(), "TestTooltip") + + self.lp.run_until_complete(exec_test()) + + def test_paint(self): + painter = QPainter() + widget = QWidget() + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "UserA", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = WotNode(("A", metadata), nx_pos) + node.paint(painter, QStyleOptionGraphicsItem(), widget) + + self.lp.run_until_complete(exec_test()) + + @patch('PyQt5.QtGui.QPainter', spec=QPainter) + @patch('PyQt5.QtWidgets.QWidget') + def test_bounding_rect(self, painter, widget): + metadata = { + 'status': NodeStatus.NEUTRAL, + 'text': "A", + 'tooltip': "TestTooltip" + } + nx_pos = { + "A": (0, 5), + "B": (10, 20) + } + async def exec_test(): + node = WotNode(("A", metadata), nx_pos) + bounding_rect = node.boundingRect() + self.assertAlmostEqual(bounding_rect.x(), -0.5, delta=1) + self.assertAlmostEqual(bounding_rect.y(), -0.5, delta=1) + self.assertAlmostEqual(bounding_rect.width(), 19.59375, delta=5) + self.assertAlmostEqual(bounding_rect.height(), 37.0, delta=5) + + self.lp.run_until_complete(exec_test())