From d044148f13ecb59b585d7a4107c2881dd528d4e9 Mon Sep 17 00:00:00 2001 From: Shelly Belsky <71195502+sbelsk@users.noreply.github.com> Date: Tue, 2 Jan 2024 12:02:35 -0500 Subject: [PATCH] Migrate the SubProject Dependencies page to Vue (#1882) This PR continues our ongoing effort to migrate from AngularJS to Vue. --- app/Http/Controllers/SubProjectController.php | 19 +- .../public/css/d3.dependencyedgebundling.css | 113 ------ .../public/js/d3.dependencyedgebundling.js | 196 ---------- app/cdash/public/views/viewSubProjects.html | 2 +- app/cdash/tests/CMakeLists.txt | 1 + app/cdash/tests/js/e2e_tests/CMakeLists.txt | 1 + phpstan-baseline.neon | 5 - resources/js/app.js | 2 + .../js/components/SubProjectDependencies.vue | 339 ++++++++++++++++++ .../shared/DependencyEdgeBundling.js | 233 ++++++++++++ .../project/subproject-dependencies.blade.php | 119 +----- routes/api.php | 2 + routes/web.php | 8 +- tests/Feature/SubProjectDependencies.php | 37 ++ .../e2e/sub-project-dependencies.cy.js | 34 ++ 15 files changed, 673 insertions(+), 438 deletions(-) delete mode 100644 app/cdash/public/css/d3.dependencyedgebundling.css delete mode 100644 app/cdash/public/js/d3.dependencyedgebundling.js create mode 100644 resources/js/components/SubProjectDependencies.vue create mode 100644 resources/js/components/shared/DependencyEdgeBundling.js create mode 100644 tests/Feature/SubProjectDependencies.php create mode 100644 tests/cypress/e2e/sub-project-dependencies.cy.js diff --git a/app/Http/Controllers/SubProjectController.php b/app/Http/Controllers/SubProjectController.php index 4a7ee3a0d8..77bef2f725 100644 --- a/app/Http/Controllers/SubProjectController.php +++ b/app/Http/Controllers/SubProjectController.php @@ -7,7 +7,7 @@ use App\Utils\PageTimer; use CDash\Model\SubProject; use Illuminate\Http\JsonResponse; -use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; @@ -108,12 +108,13 @@ public function apiManageSubProject(): JsonResponse return response()->json(cast_data_for_JSON($response)); } - public function dependenciesGraph(): View|RedirectResponse + public function dependenciesGraph(Request $request, string $project): View { - $this->setProjectByName($_GET['project'] ?? ''); + $this->setProjectByName($project); - return view('project.subproject-dependencies') - ->with('project', $this->project); + return $this->view('project.subproject-dependencies')->with([ + 'date' => $request->string('date'), + ]); } public function apiViewSubProjects(): JsonResponse @@ -169,6 +170,7 @@ public function apiViewSubProjects(): JsonResponse $linkparams .= "&date=$date"; } $response['linkparams'] = $linkparams; + $response['linkdate'] = $date; // Menu definition $menu_response = []; @@ -284,7 +286,7 @@ public function apiViewSubProjects(): JsonResponse return response()->json(cast_data_for_JSON($response)); } - public function ajaxDependenciesGraph(): JsonResponse + public function apiDependenciesGraph(): JsonResponse { $this->setProjectByName(htmlspecialchars($_GET['project'] ?? '')); @@ -304,6 +306,7 @@ public function ajaxDependenciesGraph(): JsonResponse $subarray = [ 'name' => $subproject->name, 'id' => $subproject->id, + 'depends' => [], ]; if ($subproject->groupid > 0) { @@ -317,6 +320,8 @@ public function ajaxDependenciesGraph(): JsonResponse } $result[] = $subarray; } - return response()->json(cast_data_for_JSON($result)); + return response()->json([ + 'dependencies' => $result, + ]); } } diff --git a/app/cdash/public/css/d3.dependencyedgebundling.css b/app/cdash/public/css/d3.dependencyedgebundling.css deleted file mode 100644 index 7fee19d8d9..0000000000 --- a/app/cdash/public/css/d3.dependencyedgebundling.css +++ /dev/null @@ -1,113 +0,0 @@ -.node { - /* font: 400 14px "Helvetica Neue", Helvetica, Arial, sans-serif; */ - font-weight: 400; - font-size: 13px; - fill: #888; - font-family: 'Open Sans', sans-serif; - opacity: 0.8; -} - -.node:hover { - fill: #000; - cursor: hand; - cursor: pointer; -} - -.link { - stroke: steelblue; - stroke-opacity: .4; - fill: none; - pointer-events: none; - opacity: 0.4; -} - -.node:hover, -.node--source, -.node--target { - fill: #000; - font-weight: 700; - opacity: 1; -} - -.node--source { - fill: #2ca02c; -} - -.node--target { - fill: #d62728; -} - -.link--source, -.link--target { - stroke-opacity: 1; - stroke-width: 2px; - opacity: 1; -} - -.link--source { - stroke: #d62728; -} - -.link--target { - stroke: #2ca02c; -} - -div.tooltip { - position: absolute; /* reference for measurement */ - text-align: left; - pointer-events: none; /* 'none' tells the mouse to ignore the rectangle */ - background: #E0E0E0; - padding: 10px; - border: 1px solid #D5D5D5; - font-family: arial,helvetica,sans-serif; - position: absolute; - font-size: 1.1em; - color: #333; - padding: 10px; - border-radius: 3px; - background: rgba(255,255,255,0.9); - color: #000; - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - -moz-box-shadow: 0 1px 5px rgba(0,0,0,0.4); - border:1px solid rgba(200,200,200,0.85); - z-index: 10000; -} - -div.tooltipTail { - position: absolute; - left:-7px; - top: 12px; - width: 7px; - height: 13px; - background: url("../img/tail_white.png") 50% 0%; -} - -div.toolTipBody { - position:absolute; - height:100px; - width:230px; -} - -#toolTip .header { - text-align: left; - font-size: 14px; - margin-bottom: 2px; - color:#000; - font-weight: 700; -} - -div.header-rule{ - height:1px; - margin:1px auto 3px; - margin-top:7px; - margin-bottom:7px; - background:#ddd; - width:125px; -} - -div.header1{ - text-align: left; - font-size: 12px; - margin-bottom: 2px; - color:black; -} diff --git a/app/cdash/public/js/d3.dependencyedgebundling.js b/app/cdash/public/js/d3.dependencyedgebundling.js deleted file mode 100644 index afb4e32284..0000000000 --- a/app/cdash/public/js/d3.dependencyedgebundling.js +++ /dev/null @@ -1,196 +0,0 @@ -d3.chart = d3.chart || {}; - -/** - * Dependency edge bundling chart for d3.js - * - * Usage: - * var chart = d3.chart.dependencyedgebundling(); - * d3.select('#chart_placeholder') - * .datum(data) - * .call(chart); - */ -d3.chart.dependencyedgebundling = function(options) { - - var diameter ; - var radius ; - var textRadius ; - var innerRadius = radius - textRadius; - var txtLinkGap = 5; - var _mouseOvered, _mouseOuted; - - function resetDimension(){ - radius = diameter / 2; - innerRadius = radius - textRadius; - } - - function autoDimension(){ - // automatically resize the dimension based on total number of nodes - } - // construct the package hierarchy by group - var packageHierarchy = function (classes) { - var map = {}; - function setparent(name, data) { - var node = map[name]; - if (!node) { - node = map[name] = data || {name: name, children: []}; - if (name.length) { - if (data && data.group){ - node.parent = setparent(data.group, null); - node.parent.children.push(node); - } - else { - node.parent = map[""]; - node.parent.children.push(node); - } - node.key = name; - } - } - return node; - } - setparent("",null); - classes.forEach(function(d) { - setparent(d.name, d); - }); - - return map[""]; - } - - // Return a list of depends for the given array of nodes. - var packageDepends = function (nodes) { - var map = {}, - depends = []; - - // Compute a map from name to node. - nodes.forEach(function(d) { - map[d.name] = d; - }); - - // For each dependency, construct a link from the source to target node. - nodes.forEach(function(d) { - if (d.depends) d.depends.forEach(function(i) { - depends.push({source: map[d.name], target: map[i]}); - }); - }); - - return depends; - } - - function chart(selection) { - selection.each(function(data) { - // logic to set the size of the svg graph based on input - var item=0, maxLength=0, length=0, maxItem; - for (item in data){ - length = data[item].name.length; - if (maxLength < length) - { - maxLength = length; - maxItem = data[item].name; - } - } - var minTextWidth = 7.4; - var radialTextHeight = 9.8; - var minTextRadius = Math.ceil(maxLength * minTextWidth); - var minInnerRadius = Math.ceil((radialTextHeight * data.length)/2/Math.PI); - if (minInnerRadius < 140) - { - minInnerRadius = 140; - } - var minDiameter = 2 * (minTextRadius + minInnerRadius + txtLinkGap + 2); - diameter = minDiameter; - textRadius = minTextRadius; - resetDimension(); - var root = data; - // create the layout - var cluster = d3.layout.cluster() - .size([360, innerRadius]) - .sort(null) - .value(function(d) {return d.size; }); - - var bundle = d3.layout.bundle(); - - var line = d3.svg.line.radial() - .interpolate("bundle") - .tension(.9) - .radius(function(d) { return d.y; }) - .angle(function(d) { return d.x / 180 * Math.PI; }); - - var svg = selection.insert("svg") - .attr("width", diameter) - .attr("height", diameter) - .append("g") - .attr("transform", "translate(" + radius + "," + radius + ")"); - - // get all the link and node - var link = svg.append("g").selectAll(".link"), - node = svg.append("g").selectAll(".node"); - - var nodes = cluster.nodes(packageHierarchy(root)), - links = packageDepends(nodes); - - link = link - .data(bundle(links)) - .enter().append("path") - .each(function(d) { d.source = d[0], d.target = d[d.length - 1]; }) - .attr("class", "link") - .attr("d", line); - - node = node - .data(nodes.filter(function(n) { return !n.children; })) - .enter().append("text") - .attr("class", "node") - .attr("dy", ".31em") - .attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + (d.y + txtLinkGap) + ",0)" + (d.x < 180 ? "" : "rotate(180)"); }) - .style("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; }) - .text(function(d) { return d.key; }) - .on("mouseover", mouseovered) - .on("mouseout", mouseouted); - - function mouseovered(d) { - - node - .each(function(n) { n.target = n.source = false; }); - - link - .classed("link--target", function(l) { if (l.target === d) return l.source.source = true; }) - .classed("link--source", function(l) { if (l.source === d) return l.target.target = true; }) - .filter(function(l) { return l.target === d || l.source === d; }) - .each(function() { this.parentNode.appendChild(this); }); - - node - .classed("node--target", function(n) { return n.target; }) - .classed("node--source", function(n) { return n.source; }); - - _mouseOvered(d); - } - - function mouseouted(d) { - link - .classed("link--target", false) - .classed("link--source", false); - - node - .classed("node--target", false) - .classed("node--source", false) - .text(function(d) {return d.key;}); - - _mouseOuted(d); - - } - - }); - } - - chart.mouseOvered = function (d) { - if (!arguments.length) return d; - _mouseOvered = d; - return chart; - }; - - chart.mouseOuted = function (d) { - if (!arguments.length) return d; - _mouseOuted = d; - return chart; - }; - - return chart; -}; diff --git a/app/cdash/public/views/viewSubProjects.html b/app/cdash/public/views/viewSubProjects.html index e6be477455..22aff49435 100644 --- a/app/cdash/public/views/viewSubProjects.html +++ b/app/cdash/public/views/viewSubProjects.html @@ -121,6 +121,6 @@

Project


- + [SubProject Dependencies] diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 6b5117bc20..3a7743b0ee 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -228,6 +228,7 @@ add_php_test(redundanttests) add_php_test(configureappend) add_php_test(notesparsererrormessages) add_php_test(viewsubprojectslinkoption) +add_laravel_test(/Feature/SubProjectDependencies) add_subdirectory(ctest) diff --git a/app/cdash/tests/js/e2e_tests/CMakeLists.txt b/app/cdash/tests/js/e2e_tests/CMakeLists.txt index 95ad86e83c..e13fa2e575 100644 --- a/app/cdash/tests/js/e2e_tests/CMakeLists.txt +++ b/app/cdash/tests/js/e2e_tests/CMakeLists.txt @@ -18,6 +18,7 @@ function(add_protractor_test test_name) endfunction() add_cypress_test(manage-overview) +add_cypress_test(sub-project-dependencies) add_protractor_test(manageBuildGroup) add_protractor_test(manageSubProject) add_protractor_test(viewBuildError) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c777216d9d..61a92e37ee 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2420,11 +2420,6 @@ parameters: count: 2 path: app/Http/Controllers/SubProjectController.php - - - message: "#^Method App\\\\Http\\\\Controllers\\\\SubProjectController\\:\\:dependenciesGraph\\(\\) never returns Illuminate\\\\Http\\\\RedirectResponse so it can be removed from the return type\\.$#" - count: 1 - path: app/Http/Controllers/SubProjectController.php - - message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" count: 1 diff --git a/resources/js/app.js b/resources/js/app.js index 14266a059e..d8cf7145dd 100755 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -21,6 +21,7 @@ import HeaderMenu from './components/page-header/HeaderMenu.vue'; import HeaderLogo from './components/page-header/HeaderLogo.vue'; import ViewDynamicAnalysis from './components/ViewDynamicAnalysis.vue'; import AllProjects from './components/AllProjects.vue'; +import SubProjectDependencies from './components/SubProjectDependencies.vue'; const cdash_components = { BuildConfigure, @@ -38,6 +39,7 @@ const cdash_components = { HeaderLogo, ViewDynamicAnalysis, AllProjects, + SubProjectDependencies, }; /** diff --git a/resources/js/components/SubProjectDependencies.vue b/resources/js/components/SubProjectDependencies.vue new file mode 100644 index 0000000000..3281b8b320 --- /dev/null +++ b/resources/js/components/SubProjectDependencies.vue @@ -0,0 +1,339 @@ + + + + + + + diff --git a/resources/js/components/shared/DependencyEdgeBundling.js b/resources/js/components/shared/DependencyEdgeBundling.js new file mode 100644 index 0000000000..53bb3c301b --- /dev/null +++ b/resources/js/components/shared/DependencyEdgeBundling.js @@ -0,0 +1,233 @@ +export default { + /** + * Dependency edge bundling chart for d3.js + * + * Usage: + * const chart = DependencyEdgeBundling.initChart(); + * d3.select('#chart_placeholder') + * .datum(data) + * .call(chart); + */ + initChart: function(options) { + + let diameter ; + let radius ; + let textRadius ; + let innerRadius = radius - textRadius; + const txtLinkGap = 5; + let _mouseOvered, _mouseOuted; + + function resetDimension() { + radius = diameter / 2; + innerRadius = radius - textRadius; + } + + // construct the package hierarchy by group + const packageHierarchy = function (classes) { + const map = {}; + function setparent(name, data) { + let node = map[name]; + if (!node) { + node = map[name] = data || {name: name, children: []}; + if (name.length) { + if (data && data.group) { + node.parent = setparent(data.group, null); + node.parent.children.push(node); + } + else { + node.parent = map['']; + node.parent.children.push(node); + } + node.key = name; + } + } + return node; + } + setparent('', null); + classes.forEach((d) => { + setparent(d.name, d); + }); + + return map['']; + }; + + // Return a list of depends for the given array of nodes. + const packageDepends = function (nodes) { + const map = {}; + const depends = []; + + // Compute a map from name to node. + nodes.forEach((d) => { + map[d.name] = d; + }); + + // For each dependency, construct a link from the source to target node. + nodes.forEach((d) => { + if (d.depends) { + d.depends.forEach((i) => { + depends.push({source: map[d.name], target: map[i]}); + }); + } + }); + + return depends; + }; + + function chart(selection) { + selection.each((data) => { + // logic to set the size of the svg graph based on input + let item=0, maxLength=0, length=0, maxItem; + for (item in data) { + length = data[item].name.length; + if (maxLength < length) { + maxLength = length; + maxItem = data[item].name; + } + } + const minTextWidth = 7.4; + const radialTextHeight = 9.8; + const minTextRadius = Math.ceil(maxLength * minTextWidth); + let minInnerRadius = Math.ceil((radialTextHeight * data.length)/2/Math.PI); + if (minInnerRadius < 140) { + minInnerRadius = 140; + } + const minDiameter = 2 * (minTextRadius + minInnerRadius + txtLinkGap + 2); + diameter = minDiameter; + textRadius = minTextRadius; + resetDimension(); + const root = data; + // create the layout + const cluster = d3.layout.cluster() + .size([360, innerRadius]) + .sort(null) + .value((d) => { + return d.size; + }); + + const bundle = d3.layout.bundle(); + + const line = d3.svg.line.radial() + .interpolate('bundle') + .tension(.9) + .radius((d) => { + return d.y; + }) + .angle((d) => { + return d.x / 180 * Math.PI; + }); + + const svg = selection.insert('svg') + .attr('width', diameter) + .attr('height', diameter) + .append('g') + .attr('transform', `translate(${radius},${radius})`); + + // get all the link and node + let link = svg.append('g').selectAll('.link'); + let node = svg.append('g').selectAll('.node'); + + const nodes = cluster.nodes(packageHierarchy(root)); + const links = packageDepends(nodes); + + link = link + .data(bundle(links)) + .enter().append('path') + .each((d) => { + d.source = d[0], d.target = d[d.length - 1]; + }) + .attr('class', 'link') + .attr('d', line); + + node = node + .data(nodes.filter((n) => { + return !n.children; + })) + .enter().append('text') + .attr('class', 'node') + .attr('dy', '.31em') + .attr('transform', (d) => { + return `rotate(${d.x - 90})translate(${d.y + txtLinkGap},0)${d.x < 180 ? '' : 'rotate(180)'}`; + }) + .style('text-anchor', (d) => { + return d.x < 180 ? 'start' : 'end'; + }) + .text((d) => { + return d.key; + }) + .on('mouseover', mouseovered) + .on('mouseout', mouseouted); + + function mouseovered(d) { + + node + .each((n) => { + n.target = n.source = false; + }); + + link + .classed('link--target', (l) => { + if (l.target === d) { + return l.source.source = true; + } + }) + .classed('link--source', (l) => { + if (l.source === d) { + return l.target.target = true; + } + }) + .filter((l) => { + return l.target === d || l.source === d; + }) + .each(function() { + this.parentNode.appendChild(this); + }); + + node + .classed('node--target', (n) => { + return n.target; + }) + .classed('node--source', (n) => { + return n.source; + }); + + _mouseOvered(d); + } + + function mouseouted(d) { + link + .classed('link--target', false) + .classed('link--source', false); + + node + .classed('node--target', false) + .classed('node--source', false) + .text((d) => { + return d.key; + }); + + _mouseOuted(d); + + } + + }); + } + + chart.mouseOvered = function (d) { + if (!arguments.length) { + return d; + } + _mouseOvered = d; + return chart; + }; + + chart.mouseOuted = function (d) { + if (!arguments.length) { + return d; + } + _mouseOuted = d; + return chart; + }; + + return chart; + }, +}; diff --git a/resources/views/project/subproject-dependencies.blade.php b/resources/views/project/subproject-dependencies.blade.php index c6a069356f..27642a941f 100644 --- a/resources/views/project/subproject-dependencies.blade.php +++ b/resources/views/project/subproject-dependencies.blade.php @@ -1,118 +1,11 @@ @extends('cdash', [ - 'title' => 'SubProject Dependencies Graph' + 'vue' => true, + 'title' => 'SubProject Dependencies Graph', ]) @section('main_content') -
- - - - -
-
- This circle plot captures the interrelationships among subgroups. Mouse over any of the subgroup in this graph to see incoming links (dependents) in green and the outgoing links (dependencies) in red. -
-
-
- -
-
-
-
-
-
- - - - + @endsection diff --git a/routes/api.php b/routes/api.php index 0fb9a60f5a..d4ac331f89 100755 --- a/routes/api.php +++ b/routes/api.php @@ -41,6 +41,8 @@ Route::get('/v1/viewSubProjects.php', 'SubProjectController@apiViewSubProjects'); +Route::get('/v1/getSubProjectDependencies.php', 'SubProjectController@apiDependenciesGraph'); + Route::get('/v1/viewDynamicAnalysis.php', 'DynamicAnalysisController@apiViewDynamicAnalysis'); Route::get('/v1/viewDynamicAnalysisFile.php', 'DynamicAnalysisController@apiViewDynamicAnalysisFile'); diff --git a/routes/web.php b/routes/web.php index 206e61dfb1..440104dbe0 100755 --- a/routes/web.php +++ b/routes/web.php @@ -175,9 +175,11 @@ Route::get('/manageSubProject.php', 'SubProjectController@manageSubProject'); -Route::get('/viewSubProjectDependenciesGraph.php', 'SubProjectController@dependenciesGraph'); -// TODO: (williamjallen) Replace this /ajax route with an equivalent /api route -Route::get('/ajax/getsubprojectdependencies.php', 'SubProjectController@ajaxDependenciesGraph'); +Route::get('/projects/{project}/subprojects/dependencies', 'SubProjectController@dependenciesGraph'); +Route::get('/viewSubProjectDependenciesGraph.php', function (Request $request) { + $project = $request->string('project'); + return redirect("/projects/{$project}/subprojects/dependencies", 301); +}); Route::match(['get', 'post'], '/sites/{siteid}', 'SiteController@viewSite')->whereNumber('siteid'); Route::get('/viewSite.php', function (Request $request) { diff --git a/tests/Feature/SubProjectDependencies.php b/tests/Feature/SubProjectDependencies.php new file mode 100644 index 0000000000..2638e726a1 --- /dev/null +++ b/tests/Feature/SubProjectDependencies.php @@ -0,0 +1,37 @@ +getJson($api_route)->assertJsonStructure([ + 'error', + 'code', + ]); + $_GET['project'] = 'NoSuchProject'; // hack until we migrate to laravel + $this->getJson($api_route)->assertJsonStructure([ + 'error', + 'code', + ]); + + // verify api response for non-empty project + $_GET['project'] = 'SubProjectExample'; // hack until we migrate to laravel + $_GET['date'] = '2009-08-06 12:19:56'; + $this->getJson($api_route)->assertJsonStructure([ + 'dependencies' => [ + [ + "name", + "id", + "depends", + ], + ], + ]); + } +} diff --git a/tests/cypress/e2e/sub-project-dependencies.cy.js b/tests/cypress/e2e/sub-project-dependencies.cy.js new file mode 100644 index 0000000000..9459e04e2f --- /dev/null +++ b/tests/cypress/e2e/sub-project-dependencies.cy.js @@ -0,0 +1,34 @@ +describe('SubProjectDependencies', () => { + it('loads the dependency graph', () => { + cy.visit('projects/SubProjectExample/subprojects/dependencies?2015-01-28%2014:36:08'); + + cy.get('[data-cy="svg-wrapper"]').should('have.descendants', 'svg'); + }); + + + it('can interact with the dependency graph', () => { + cy.visit('projects/SubProjectExample/subprojects/dependencies?2015-01-28%2014:36:08'); + + // check that the graph sorting works + cy.get('[data-cy="select-sorting-order"]').find('option').contains('subproject name').should('be.selected'); + cy.get('text.node').first().should('contain', 'Amesos'); + cy.get('[data-cy="select-sorting-order"]').select('subproject id'); + cy.get('text.node').first().should('contain', 'Teuchos'); + cy.get('[data-cy="select-sorting-order"]').select('subproject name'); // restore to default + + // check tooltip displays as expected + cy.get('[data-cy="tooltip"]').should('have.css', 'opacity', '0'); // initially hidden + cy.get('text.node').first().trigger('mouseover').then((d) => { + // tooltip becomes visible on hover + cy.get('[data-cy="tooltip"]').should('have.css', 'opacity', '0.9'); + cy.get('[data-cy="tooltip-name-header"]').should('contain', 'Amesos'); + // 'Anasazi' is known to be a dependent of 'Amesos' + cy.get('text.node').filter(':contains("Anasazi")').first().should('have.class', 'node--source'); + // 'Amesos' is known to depend on 'Teuchos' + cy.get('text.node').filter(':contains("Teuchos")').first().should('have.class', 'node--target'); + }); + cy.get('text.node').first().trigger('mouseout').then((d) => { + cy.get('[data-cy="tooltip"]').should('have.css', 'opacity', '0'); // should be hidden again + }); + }); +});