[Feature] Threshold Warning + Cosmetics (#277)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#277
This commit is contained in:
2025-12-20 02:11:47 +13:00
parent a4a4179261
commit 7c60a28801
10 changed files with 304 additions and 165 deletions

View File

@ -15,6 +15,8 @@ jobs:
api-tests: api-tests:
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }} if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: git.envipath.com/envipath/envipy-ci:latest
services: services:
postgres: postgres:
@ -67,19 +69,17 @@ jobs:
uses: ./.gitea/actions/setup-envipy uses: ./.gitea/actions/setup-envipy
with: with:
skip-frontend: 'true' skip-frontend: 'true'
skip-playwright: 'true' skip-playwright: 'false'
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }} ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
run-migrations: 'true' run-migrations: 'true'
- name: Run API tests - name: Run API tests
run: | run: |
source .venv/bin/activate .venv/bin/python manage.py test epapi -v 2
python manage.py test epapi -v 2
- name: Test API endpoints availability - name: Test API endpoints availability
run: | run: |
source .venv/bin/activate .venv/bin/python manage.py runserver 0.0.0.0:8000 &
python manage.py runserver 0.0.0.0:8000 &
SERVER_PID=$! SERVER_PID=$!
sleep 5 sleep 5
curl -f http://localhost:8000/api/v1/docs || echo "API docs not available" curl -f http://localhost:8000/api/v1/docs || echo "API docs not available"

View File

@ -12,9 +12,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: git.envipath.com/envipath/envipy-ci:latest image: git.envipath.com/envipath/envipy-ci:latest
credentials:
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
services: services:
postgres: postgres:

View File

@ -1489,6 +1489,7 @@ class SPathway(object):
self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes}) self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes})
self.edges: Set["SEdge"] = set() self.edges: Set["SEdge"] = set()
self.done = False self.done = False
self.empty_due_to_threshold = False
@staticmethod @staticmethod
def from_pathway(pw: "Pathway", persist: bool = True): def from_pathway(pw: "Pathway", persist: bool = True):
@ -1601,9 +1602,24 @@ class SPathway(object):
sub.app_domain_assessment = app_domain_assessment sub.app_domain_assessment = app_domain_assessment
candidates = self.prediction_setting.expand(self, sub) expansion_result = self.prediction_setting.expand(self, sub)
# We don't have any substrate, but technically we have at least one rule that triggered.
# If our substrate is a root node a.k.a. depth == 0 store that info in SPathway
if (
len(expansion_result["transformations"]) == 0
and expansion_result["rule_triggered"]
and sub.depth == 0
):
self.empty_due_to_threshold = True
# Emit directly
if self.persist is not None:
self.persist.kv["empty_due_to_threshold"] = True
self.persist.save()
# candidates is a List of PredictionResult. The length of the List is equal to the number of rules # candidates is a List of PredictionResult. The length of the List is equal to the number of rules
for cand_set in candidates: for cand_set in expansion_result["transformations"]:
if cand_set: if cand_set:
# cand_set is a PredictionResult object that can consist of multiple candidate reactions # cand_set is a PredictionResult object that can consist of multiple candidate reactions
for cand in cand_set: for cand in cand_set:
@ -1727,10 +1743,6 @@ class SPathway(object):
for queued_val in queue: for queued_val in queue:
node_and_probs.append((queued_val, node_probs[queued_val])) node_and_probs.append((queued_val, node_probs[queued_val]))
from pprint import pprint
pprint(node_and_probs)
# re-order the queue and only pick smiles # re-order the queue and only pick smiles
queue = [ queue = [
n[0] for n in sorted(node_and_probs, key=lambda x: x[1], reverse=True) n[0] for n in sorted(node_and_probs, key=lambda x: x[1], reverse=True)

View File

@ -23,7 +23,7 @@ from django.db import models, transaction
from django.db.models import Count, JSONField, Q, QuerySet from django.db.models import Count, JSONField, Q, QuerySet
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from envipy_additional_information import EnviPyModel from envipy_additional_information import EnviPyModel, HalfLife
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from sklearn.metrics import jaccard_score, precision_score, recall_score from sklearn.metrics import jaccard_score, precision_score, recall_score
@ -795,9 +795,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
@property @property
def related_pathways(self): def related_pathways(self):
pathways = Node.objects.filter(node_labels__in=[self.default_structure]).values_list( pathways = self.related_nodes.values_list("pathway", flat=True)
"pathway", flat=True
)
return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by("name") return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by("name")
@property @property
@ -807,6 +805,12 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
| Reaction.objects.filter(package=self.package, products__in=[self.default_structure]) | Reaction.objects.filter(package=self.package, products__in=[self.default_structure])
).order_by("name") ).order_by("name")
@property
def related_nodes(self):
return Node.objects.filter(
node_labels__in=[self.default_structure], pathway__package=self.package
)
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
@ -1042,6 +1046,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
return new_compound return new_compound
def half_lifes(self):
hls: Dict[Scenario, List[HalfLife]] = defaultdict(list)
for n in self.related_nodes:
for scen in n.scenarios.all().order_by("name"):
for ai in scen.get_additional_information():
if isinstance(ai, HalfLife):
hls[scen].append(ai)
return dict(hls)
class Meta: class Meta:
unique_together = [("uuid", "package")] unique_together = [("uuid", "package")]
@ -1780,6 +1795,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
def failed(self): def failed(self):
return self.status() == "failed" return self.status() == "failed"
def empty_due_to_threshold(self):
return self.kv.get("empty_due_to_threshold", False)
def d3_json(self): def d3_json(self):
# Ideally it would be something like this but # Ideally it would be something like this but
# to reduce crossing in edges do a DFS # to reduce crossing in edges do a DFS
@ -1887,7 +1905,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"status": self.status(), "status": self.status(),
} }
return json.dumps(res) return res
def to_csv(self, include_header=True, include_pathway_url=False) -> str: def to_csv(self, include_header=True, include_pathway_url=False) -> str:
import csv import csv
@ -3887,33 +3905,48 @@ class Setting(EnviPathModel):
rules = sorted(rules, key=lambda x: x.url) rules = sorted(rules, key=lambda x: x.url)
return rules return rules
def expand(self, pathway, current_node): def expand(self, pathway, current_node) -> Dict[str, Any]:
res: Dict[str, Any] = defaultdict(list)
"""Decision Method whether to expand on a certain Node or not""" """Decision Method whether to expand on a certain Node or not"""
if pathway.num_nodes() >= self.max_nodes: if pathway.num_nodes() >= self.max_nodes:
logger.info( logger.info(
f"Pathway has {pathway.num_nodes()} Nodes which exceeds the limit of {self.max_nodes}" f"Pathway has {pathway.num_nodes()} Nodes which exceeds the limit of {self.max_nodes}"
) )
return [] res["expansion_skipped"] = True
return res
if pathway.depth() >= self.max_depth: if pathway.depth() >= self.max_depth:
logger.info( logger.info(
f"Pathway has reached depth {pathway.depth()} which exceeds the limit of {self.max_depth}" f"Pathway has reached depth {pathway.depth()} which exceeds the limit of {self.max_depth}"
) )
return [] res["expansion_skipped"] = True
return res
transformations = []
if self.model is not None: if self.model is not None:
pred_results = self.model.predict(current_node.smiles) pred_results = self.model.predict(current_node.smiles)
# Store whether there are results that may be removed as they are below
# the given threshold
if len(pred_results):
res["rule_triggered"] = True
for pred_result in pred_results: for pred_result in pred_results:
if pred_result.probability >= self.model_threshold: if (
transformations.append(pred_result) len(pred_result.product_sets)
and pred_result.probability >= self.model_threshold
):
res["transformations"].append(pred_result)
else: else:
for rule in self.applicable_rules: for rule in self.applicable_rules:
tmp_products = rule.apply(current_node.smiles) tmp_products = rule.apply(current_node.smiles)
if tmp_products: if tmp_products:
transformations.append(PredictionResult(tmp_products, 1.0, rule)) res["transformations"].append(PredictionResult(tmp_products, 1.0, rule))
return transformations if len(res["transformations"]):
res["rule_triggered"] = True
return res
@transaction.atomic @transaction.atomic
def make_global_default(self): def make_global_default(self):

View File

@ -1937,6 +1937,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
{ {
"status": current_pathway.status(), "status": current_pathway.status(),
"modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"), "modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"),
"emptyDueToThreshold": current_pathway.empty_due_to_threshold(),
} }
) )

View File

@ -22,13 +22,16 @@ document.addEventListener('alpine:init', () => {
status: config.status, status: config.status,
modified: config.modified, modified: config.modified,
statusUrl: config.statusUrl, statusUrl: config.statusUrl,
emptyDueToThreshold: config.emptyDueToThreshold === "True",
showUpdateNotice: false, showUpdateNotice: false,
showEmptyDueToThresholdNotice: false,
emptyDueToThresholdMessage: 'The Pathway is empty due to the selected threshold. Please try a different threshold.',
updateMessage: '', updateMessage: '',
pollInterval: null, pollInterval: null,
get statusTooltip() { get statusTooltip() {
const tooltips = { const tooltips = {
'completed': 'Pathway prediction complete.', 'completed': 'Pathway prediction completed.',
'failed': 'Pathway prediction failed.', 'failed': 'Pathway prediction failed.',
'running': 'Pathway prediction running.' 'running': 'Pathway prediction running.'
}; };
@ -39,9 +42,17 @@ document.addEventListener('alpine:init', () => {
if (this.status === 'running') { if (this.status === 'running') {
this.startPolling(); this.startPolling();
} }
if (this.emptyDueToThreshold) {
this.showEmptyDueToThresholdNotice = true;
}
}, },
startPolling() { startPolling() {
if (this.pollInterval) {
return;
}
this.pollInterval = setInterval(() => this.checkStatus(), 5000); this.pollInterval = setInterval(() => this.checkStatus(), 5000);
}, },
@ -50,9 +61,16 @@ document.addEventListener('alpine:init', () => {
const response = await fetch(this.statusUrl); const response = await fetch(this.statusUrl);
const data = await response.json(); const data = await response.json();
if (data.emptyDueToThreshold) {
this.emptyDueToThreshold = true;
this.showEmptyDueToThresholdNotice = true;
}
if (data.modified > this.modified) { if (data.modified > this.modified) {
this.showUpdateNotice = true; if (!this.emptyDueToThreshold) {
this.updateMessage = this.getUpdateMessage(data.status); this.showUpdateNotice = true;
this.updateMessage = this.getUpdateMessage(data.status);
}
} }
if (data.status !== 'running') { if (data.status !== 'running') {

View File

@ -1,11 +1,10 @@
{% load static %}
<dialog <dialog
id="search_modal" id="search_modal"
class="modal @max-sm:modal-top justify-center" class="modal items-start sm:items-center"
x-data="searchModal()" x-data="searchModal()"
@close="reset()" @close="reset()"
> >
<div class="modal-box h-full w-lvw p-1 sm:h-8/12 sm:w-[85vw] sm:max-w-5xl"> <div class="modal-box mt-4 sm:mt-0 p-1 sm:h-8/12 sm:w-[85vw] sm:max-w-5xl">
<!-- Search Input and Mode Selector --> <!-- Search Input and Mode Selector -->
<div class="form-control mb-4 w-full shrink-0"> <div class="form-control mb-4 w-full shrink-0">
<div class="join m-0 w-full items-center p-3"> <div class="join m-0 w-full items-center p-3">
@ -43,7 +42,7 @@
type="button" type="button"
tabindex="0" tabindex="0"
popovertarget="search_dropdown_menu" popovertarget="search_dropdown_menu"
style="anchor-name: --1" style="anchor-name: --anchor-mode"
class="btn join-item btn-ghost" class="btn join-item btn-ghost"
> >
<span x-text="searchModeLabel"></span> <span x-text="searchModeLabel"></span>
@ -67,7 +66,7 @@
popover popover
x-ref="modeDropdown" x-ref="modeDropdown"
id="search_dropdown_menu" id="search_dropdown_menu"
style="position-anchor: --anchor-2" style="position-anchor: --anchor-mode"
> >
<li class="menu-title">Text</li> <li class="menu-title">Text</li>
<li> <li>
@ -495,8 +494,7 @@
</div> </div>
</div> </div>
<!-- Backdrop to close -->
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button aria-label="close"></button>
</form> </form>
</dialog> </dialog>

View File

@ -181,6 +181,55 @@
</div> </div>
{% endif %} {% endif %}
{% if compound.half_lifes %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Half-lives</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Scenario</th>
<th>Values</th>
</tr>
</thead>
<tbody>
{% for scenario, half_lifes in compound.half_lifes.items %}
<tr>
<td>
<a href="{{ scenario.url }}" class="hover:bg-base-200"
>{{ scenario.name }}
<i>({{ scenario.package.name }})</i></a
>
</td>
<td>
<table class="table-zebra table">
<tbody>
<tr>
<td>Scenario Type</td>
<td>{{ scenario.scenario_type }}</td>
</tr>
<tr>
<td>Half-life (days)</td>
<td>{{ half_lifes.0.dt50 }}</td>
</tr>
<tr>
<td>Model</td>
<td>{{ half_lifes.0.model }}</td>
</tr>
</tbody>
</table>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- External Identifiers --> <!-- External Identifiers -->
{% if compound.get_external_identifiers %} {% if compound.get_external_identifiers %}
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">

View File

@ -216,59 +216,62 @@
x-data="pathwayViewer({ x-data="pathwayViewer({
status: '{{ pathway.status }}', status: '{{ pathway.status }}',
modified: '{{ pathway.modified|date:"Y-m-d H:i:s" }}', modified: '{{ pathway.modified|date:"Y-m-d H:i:s" }}',
statusUrl: '{{ pathway.url }}?status=true' statusUrl: '{{ pathway.url }}?status=true',
emptyDueToThreshold: '{{ pathway.empty_due_to_threshold }}'
})" })"
x-init="init()" x-init="init()"
> >
<!-- Status Display --> {% if pathway.predicted %}
<div class="tooltip tooltip-left absolute top-4 right-4 z-10"> <!-- Status Display -->
<div class="tooltip-content" x-text="statusTooltip"></div> <div class="tooltip tooltip-left absolute top-4 right-4 z-10">
<div id="status" class="flex items-center"> <div class="tooltip-content" x-text="statusTooltip"></div>
<!-- Completed icon --> <div id="status" class="flex items-center">
<template x-if="status === 'completed'"> <!-- Completed icon -->
<svg <template x-if="status === 'completed'">
xmlns="http://www.w3.org/2000/svg" <svg
width="16" xmlns="http://www.w3.org/2000/svg"
height="16" width="16"
viewBox="0 0 24 24" height="16"
fill="none" viewBox="0 0 24 24"
stroke="currentColor" fill="none"
stroke-width="2" stroke="currentColor"
stroke-linecap="round" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"
class="lucide lucide-check" stroke-linejoin="round"
class="lucide lucide-check"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</template>
<!-- Failed icon -->
<template x-if="status === 'failed'">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-x"
>
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</template>
<!-- Loading spinner -->
<div
x-show="status === 'running'"
style="width: 20px; height: 20px;"
> >
<path d="M20 6 9 17l-5-5" /> {% include "components/loading-spinner.html" %}
</svg> </div>
</template>
<!-- Failed icon -->
<template x-if="status === 'failed'">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-x"
>
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</template>
<!-- Loading spinner -->
<div
x-show="status === 'running'"
style="width: 20px; height: 20px;"
>
{% include "components/loading-spinner.html" %}
</div> </div>
</div> </div>
</div> {% endif %}
<!-- Update Notice --> <!-- Update Notice -->
<div <div
x-show="showUpdateNotice" x-show="showUpdateNotice"
@ -280,6 +283,15 @@
Reload page Reload page
</button> </button>
</div> </div>
<!-- Empty due to Threshold notice -->
<div
x-show="showEmptyDueToThresholdNotice"
x-cloak
class="alert alert-info absolute right-4 bottom-4 left-4 z-10"
>
<span x-html="emptyDueToThresholdMessage"></span>
</div>
<svg id="pwsvg"> <svg id="pwsvg">
<defs> <defs>
<marker <marker
@ -455,95 +467,110 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{# prettier-ignore-start #} {{ pathway.d3_json|json_script:"pathway" }}
{# FIXME: This is a hack to get the pathway data into the JavaScript code. #}
<script> <script>
// Global switch for app domain view // Global switch for app domain view
var appDomainViewEnabled = false; var appDomainViewEnabled = false;
function goFullscreen(id) { function goFullscreen(id) {
var element = document.getElementById(id); var element = document.getElementById(id);
if (element.mozRequestFullScreen) { if (element.mozRequestFullScreen) {
element.mozRequestFullScreen(); element.mozRequestFullScreen();
} else if (element.webkitRequestFullScreen) { } else if (element.webkitRequestFullScreen) {
element.webkitRequestFullScreen(); element.webkitRequestFullScreen();
} }
} }
function transformReferences(text) { function transformReferences(text) {
return text.replace(/\[\s*(http[^\]|]+)\s*\|\s*([^\]]+)\s*\]/g, '<a target="parent" href="$1">$2</a>'); return text.replace(
} /\[\s*(http[^\]|]+)\s*\|\s*([^\]]+)\s*\]/g,
'<a target="parent" href="$1">$2</a>',
);
}
var pathway = JSON.parse(document.getElementById("pathway").textContent);
var pathway = {{ pathway.d3_json | safe }}; document.addEventListener("DOMContentLoaded", function () {
draw(pathway, "vizdiv");
document.addEventListener('DOMContentLoaded', function() { // Transform references in description
draw(pathway, 'vizdiv'); const descContent = document.getElementById("DescriptionContent");
if (descContent) {
const newDesc = transformReferences(descContent.innerText);
descContent.innerHTML = newDesc;
}
// Transform references in description // App domain toggle
const descContent = document.getElementById('DescriptionContent'); const appDomainBtn = document.getElementById("app-domain-toggle-button");
if (descContent) { if (appDomainBtn) {
const newDesc = transformReferences(descContent.innerText); appDomainBtn.addEventListener("click", function () {
descContent.innerHTML = newDesc; appDomainViewEnabled = !appDomainViewEnabled;
} const icon = document.getElementById("app-domain-icon");
// App domain toggle if (appDomainViewEnabled) {
const appDomainBtn = document.getElementById('app-domain-toggle-button'); // Change to eye-off icon
if (appDomainBtn) { icon.innerHTML =
appDomainBtn.addEventListener('click', function() { '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
appDomainViewEnabled = !appDomainViewEnabled;
const icon = document.getElementById('app-domain-icon');
if (appDomainViewEnabled) { nodes.forEach((x) => {
// Change to eye-off icon if (x.app_domain) {
icon.innerHTML = '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>'; if (x.app_domain.inside_app_domain) {
d3.select(x.el)
.select("circle")
.classed("inside_app_domain", true);
} else {
d3.select(x.el)
.select("circle")
.classed("outside_app_domain", true);
}
}
});
links.forEach((x) => {
if (x.app_domain) {
if (x.app_domain.passes_app_domain) {
d3.select(x.el).attr("marker-end", (d) =>
d.target.pseudo ? "" : "url(#arrow_passes_app_domain)",
);
d3.select(x.el).classed("passes_app_domain", true);
} else {
d3.select(x.el).attr("marker-end", (d) =>
d.target.pseudo ? "" : "url(#arrow_fails_app_domain)",
);
d3.select(x.el).classed("fails_app_domain", true);
}
}
});
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
nodes.forEach((x) => { nodes.forEach((x) => {
if(x.app_domain) { d3.select(x.el)
if (x.app_domain.inside_app_domain) { .select("circle")
d3.select(x.el).select("circle").classed("inside_app_domain", true); .classed("inside_app_domain", false);
} else { d3.select(x.el)
d3.select(x.el).select("circle").classed("outside_app_domain", true); .select("circle")
} .classed("outside_app_domain", false);
} });
}); links.forEach((x) => {
links.forEach((x) => { d3.select(x.el).attr("marker-end", (d) =>
if(x.app_domain) { d.target.pseudo ? "" : "url(#arrow)",
if (x.app_domain.passes_app_domain) { );
d3.select(x.el).attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow_passes_app_domain)"); d3.select(x.el).classed("passes_app_domain", false);
d3.select(x.el).classed("passes_app_domain", true); d3.select(x.el).classed("fails_app_domain", false);
} else { });
d3.select(x.el).attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow_fails_app_domain)"); }
d3.select(x.el).classed("fails_app_domain", true);
}
}
});
} else {
// Change back to eye icon
icon.innerHTML = '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
nodes.forEach((x) => {
d3.select(x.el).select("circle").classed("inside_app_domain", false);
d3.select(x.el).select("circle").classed("outside_app_domain", false);
});
links.forEach((x) => {
d3.select(x.el).attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)");
d3.select(x.el).classed("passes_app_domain", false);
d3.select(x.el).classed("fails_app_domain", false);
});
}
});
}
// Show actions button if there are actions
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
}); });
}
</script> // Show actions button if there are actions
{# prettier-ignore-end #} const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
});
</script>
{% endblock content %} {% endblock content %}

View File

@ -51,7 +51,9 @@ class SPathwayTest(TestCase):
None, None,
) )
with patch.object(self.mock_setting, "expand", return_value=[pr]): mock_val = {"rule_triggered": True, "transformations": [pr]}
with patch.object(self.mock_setting, "expand", return_value=mock_val):
spw.predict_step(from_depth=0) spw.predict_step(from_depth=0)
self.assertEqual(len(spw.smiles_to_node.keys()), 4) self.assertEqual(len(spw.smiles_to_node.keys()), 4)
@ -72,7 +74,9 @@ class SPathwayTest(TestCase):
None, None,
) )
with patch.object(self.mock_setting, "expand", return_value=[pr]): mock_val = {"rule_triggered": True, "transformations": [pr]}
with patch.object(self.mock_setting, "expand", return_value=mock_val):
spw.predict_step(from_depth=0) spw.predict_step(from_depth=0)
self.assertEqual(len(spw.smiles_to_node.keys()), 4) self.assertEqual(len(spw.smiles_to_node.keys()), 4)