diff --git a/CHANGES.md b/CHANGES.md index 339d87d05..027312ff5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,13 @@ creating a new release entry be sure to copy & paste the span tag with the `actions:bind` attribute, which is used by a regex to find the text to be updated. Only the first match gets replaced, so it's fine to leave the old ones in. --> +------------------------------------------------------------------------------- +## __cylc-ui-2.2.1 (Upcoming)__ + +### Fixes + +[#1549](https://github.com/cylc/cylc-ui/pull/1549) - +Fix workflow filtering bug in the sidebar. ------------------------------------------------------------------------------- ## __cylc-ui-2.2.0 (Released 2023-11-02)__ diff --git a/cypress/component/cylc-graph-node.cy.js b/cypress/component/cylc-graph-node.cy.js index 0777163ee..6f6bc327f 100644 --- a/cypress/component/cylc-graph-node.cy.js +++ b/cypress/component/cylc-graph-node.cy.js @@ -16,7 +16,8 @@ */ import { defineComponent, h } from 'vue' -import { TaskStateUserOrder, JobStates } from '@/model/TaskState.model' +import { JobStateNames } from '@/model/JobState.model' +import { TaskStateNames } from '@/model/TaskState.model' import GraphNode from '@/components/cylc/GraphNode.vue' import { Tokens } from '@/utils/uid' import { @@ -132,23 +133,23 @@ describe('graph node component', () => { let task let jobs let jobStates - for (const state of TaskStateUserOrder) { + for (const state of TaskStateNames) { jobStates = [] - for (const jobState of JobStates) { - if (state.name === jobState.name) { - jobStates = [state.name] + for (const jobState of JobStateNames) { + if (state === jobState) { + jobStates = [state] break } } [task, jobs] = makeTaskNode( - `~a/b//20000101T0000Z/${state.name}`, - state.name, + `~a/b//20000101T0000Z/${state}`, + state, jobStates ) // console.log(jobs) cy.mount(GraphNodeSVG, { props: { task, jobs } }) cy.get('.c-graph-node').last().parent().screenshot( - `graph-node-${state.name}`, + `graph-node-${state}`, { overwrite: true, disableTimersAndAnimations: false } ) } diff --git a/cypress/component/cylc-icons.cy.js b/cypress/component/cylc-icons.cy.js index ff04fcb0a..5ea92dd45 100644 --- a/cypress/component/cylc-icons.cy.js +++ b/cypress/component/cylc-icons.cy.js @@ -15,7 +15,8 @@ * along with this program. If not, see . */ -import { TaskStateUserOrder, JobStates } from '@/model/TaskState.model' +import { JobStateNames } from '@/model/JobState.model' +import { TaskStateUserOrder } from '@/model/TaskState.model' import Task from '@/components/cylc/Task.vue' import Job from '@/components/cylc/Job.vue' import { @@ -147,10 +148,10 @@ describe('Task component', () => { describe('Job component', () => { it('renders for each job state', () => { - for (const state of JobStates) { - cy.mount(JobComponent, { props: { status: state.name } }) + for (const status of JobStateNames) { + cy.mount(JobComponent, { props: { status } }) cy.get('.c-job svg').last().screenshot( - `job-${state.name}`, + `job-${status}`, { overwrite: true } ) } diff --git a/src/components/cylc/Drawer.vue b/src/components/cylc/Drawer.vue index 577f6fd3f..d7cfad562 100644 --- a/src/components/cylc/Drawer.vue +++ b/src/components/cylc/Drawer.vue @@ -24,9 +24,9 @@ along with this program. If not, see . :width="drawerWidth" class="fill-height" > -
+
diff --git a/src/components/cylc/TaskFilter.vue b/src/components/cylc/TaskFilter.vue index 1bc508880..87e9d49e7 100644 --- a/src/components/cylc/TaskFilter.vue +++ b/src/components/cylc/TaskFilter.vue @@ -49,7 +49,7 @@ along with this program. If not, see . diff --git a/src/components/cylc/gscan/GScan.vue b/src/components/cylc/gscan/GScan.vue index 5f7da8b27..4c689e2af 100644 --- a/src/components/cylc/gscan/GScan.vue +++ b/src/components/cylc/gscan/GScan.vue @@ -104,13 +104,12 @@ along with this program. If not, see . class="c-gscan-workflows flex-grow-1 pl-2" >
@@ -127,10 +126,10 @@ along with this program. If not, see . diff --git a/src/components/cylc/gscan/filters.js b/src/components/cylc/gscan/filters.js index 3b9023535..ba5abff46 100644 --- a/src/components/cylc/gscan/filters.js +++ b/src/components/cylc/gscan/filters.js @@ -15,15 +15,13 @@ * along with this program. If not, see . */ -import JobState from '@/model/JobState.model' - /** * @param {WorkflowGScanNode|WorkflowNamePartGScanNode} workflow - * @param {string} name - name filter + * @param {?string} name - name filter * @returns {boolean} */ export function filterByName (workflow, name) { - return workflow.tokens.workflow.toLowerCase().includes(name.toLowerCase()) + return !name || workflow.tokens.workflow.toLowerCase().includes(name.toLowerCase()) } /** @@ -32,16 +30,9 @@ export function filterByName (workflow, name) { * @return {string[]} */ function getWorkflowStates (stateTotals) { - const jobStates = JobState.enumValues.map(jobState => jobState.name) - // GraphQL will return all the task states possible in a workflow, but we - // only want the states that have an equivalent state for a job. So we filter - // out the states that do not exist for jobs, and that have active tasks in - // the workflow (no point keeping the empty states, as they are not to be - // displayed). return !stateTotals ? [] - : Object.keys(stateTotals) - .filter((state) => jobStates.includes(state) && stateTotals[state] > 0) + : Object.keys(stateTotals).filter((state) => stateTotals[state] > 0) } /** @@ -65,54 +56,3 @@ export function filterByState (workflow, workflowStates, taskStates) { } return true } - -/** - * Filter a workflow using a given name (could be a part of - * a name) and a given list of filters. - * - * The list of filters may contain workflow states ("running", "stopped", - * "paused"), and/or task states ("running", "waiting", "submit-failed", - * etc). - * - * @param {WorkflowGScanNode|WorkflowNamePartGScanNode} workflow - * @param {string} name - * @param {string[]} workflowStates - * @param {string[]} taskStates - * @return {boolean} - true if the workflow is accepted, false otherwise - */ -function filterWorkflow (workflow, name, workflowStates, taskStates) { - // Filter by name. - if (name && !filterByName(workflow, name)) { - // Stop if we know that the name was not accepted. - return false - } - // Now filter using the provided list of states. We know that the name has been - // accepted at this point. - return filterByState(workflow, workflowStates, taskStates) -} - -/** - * @param {Array} workflows - * @param {String} name - * @param {Array} workflowStates - * @param {Array} taskStates - * @returns {Array} - filtered workflows - * @see https://stackoverflow.com/questions/45289854/how-to-effectively-filter-tree-view-retaining-its-existing-structure - */ -export function filterHierarchically (workflows, name, workflowStates, taskStates) { - const filterChildren = (result, workflowNode) => { - if (workflowNode.type === 'workflow') { - if (filterWorkflow(workflowNode, name, workflowStates, taskStates)) { - result.push(workflowNode) - return result - } - } else if (workflowNode.type === 'workflow-part' && workflowNode.children.length) { - const children = workflowNode.children.reduce(filterChildren, []) - if (children.length) { - result.push({ ...workflowNode, children }) - } - } - return result - } - return workflows.reduce(filterChildren, []) -} diff --git a/src/components/cylc/tree/GScanTreeItem.vue b/src/components/cylc/tree/GScanTreeItem.vue index 2a0f79c3a..4516f3dc9 100644 --- a/src/components/cylc/tree/GScanTreeItem.vue +++ b/src/components/cylc/tree/GScanTreeItem.vue @@ -17,7 +17,7 @@ along with this program. If not, see . @@ -92,11 +92,9 @@ along with this program. If not, see . import Job from '@/components/cylc/Job.vue' import WorkflowIcon from '@/components/cylc/gscan/WorkflowIcon.vue' import TreeItem from '@/components/cylc/tree/TreeItem.vue' -import { JobStates } from '@/model/TaskState.model' +import { JobStateNames } from '@/model/JobState.model' import { WorkflowState } from '@/model/WorkflowState.model' -const JobStateNames = JobStates.map(({ name }) => name) - /** * Get aggregated task state totals and latest task states for all descendents of a node. * @@ -150,6 +148,10 @@ export default { type: Number, default: 0 }, + filteredOutNodesCache: { + type: WeakMap, + required: true, + }, hoverable: { type: Boolean, }, @@ -189,5 +191,6 @@ export default { }, nodeTypes: ['workflow-part', 'workflow'], + maxTasksDisplayed: 5, } diff --git a/src/components/cylc/tree/Tree.vue b/src/components/cylc/tree/Tree.vue index deb0dc9b2..ecdab28cb 100644 --- a/src/components/cylc/tree/Tree.vue +++ b/src/components/cylc/tree/Tree.vue @@ -18,85 +18,21 @@ along with this program. If not, see . diff --git a/src/components/cylc/tree/TreeItem.vue b/src/components/cylc/tree/TreeItem.vue index 212ba7e36..46d173ebf 100644 --- a/src/components/cylc/tree/TreeItem.vue +++ b/src/components/cylc/tree/TreeItem.vue @@ -17,7 +17,7 @@ along with this program. If not, see . @@ -193,19 +192,6 @@ import { } from '@/utils/tasks' import { getNodeChildren } from '@/components/cylc/tree/util' -/** - * Events that are passed through up the chain from child TreeItems. - * - * i.e. they are re-emitted by this TreeItem when they occur on a - * child TreeItem, all the way up to the parent Tree component. - */ -const passthroughEvents = [ - 'tree-item-created', - 'tree-item-destroyed', - 'tree-item-expanded', - 'tree-item-collapsed', -] - export const defaultNodeIndent = 28 // px /** Margin between expand/collapse btn & node content */ @@ -239,11 +225,21 @@ export default { default: true }, hoverable: Boolean, + /** Render expanded initially if node is one of these types. */ autoExpandTypes: { type: Array, required: false, default: () => ['workflow', 'cycle', 'family'] }, + /** When this changes, will expand if node is one of these types, otherwise collapse. */ + expandAll: { + type: Array, + required: false, + }, + filteredOutNodesCache: { + type: WeakMap, + required: true, + }, /** Indent in px; default is expand/collapse btn width */ indent: { type: Number, @@ -257,15 +253,8 @@ export default { }, }, - emits: [ - ...passthroughEvents - ], - data () { return { - active: false, - selected: false, - filtered: true, manuallyExpanded: null, } }, @@ -311,7 +300,6 @@ export default { nodeClass () { return { 'node--hoverable': this.hoverable, - 'node--active': this.active, expanded: this.isExpanded } }, @@ -334,39 +322,19 @@ export default { }, }, - created () { - this.$emit('tree-item-created', this) - this.passthroughHandlers = Object.fromEntries( - passthroughEvents.map((eventName) => ( - [eventName, (treeItem) => this.$emit(eventName, treeItem)] - )) - ) - }, - - beforeUnmount () { - this.$emit('tree-item-destroyed', this) - }, - - beforeMount () { - this.emitExpandCollapseEvent(this.isExpanded) + watch: { + expandAll (nodeTypes) { + if (nodeTypes?.includes(this.node.type)) { + this.isExpanded = true + } else if (nodeTypes?.length === 0) { + this.isExpanded = false // manually collapsed + } + } }, methods: { toggleExpandCollapse () { this.isExpanded = !this.isExpanded - this.emitExpandCollapseEvent(this.isExpanded) - }, - /** - * Emits an event `tree-item-expanded` if `expanded` is true, or emits - * `tree-item-collapsed` if `expanded` is false. - * @param {boolean} expanded whether the node is expanded or not - */ - emitExpandCollapseEvent (expanded) { - if (expanded) { - this.$emit('tree-item-expanded', this) - } else { - this.$emit('tree-item-collapsed', this) - } }, latestJob }, diff --git a/src/model/JobState.model.js b/src/model/JobState.model.js index 81870882d..64746c284 100644 --- a/src/model/JobState.model.js +++ b/src/model/JobState.model.js @@ -40,4 +40,6 @@ class JobState extends Enumify { } } +export const JobStateNames = JobState.enumValues.map(({ name }) => name) + export default JobState diff --git a/src/model/TaskState.model.js b/src/model/TaskState.model.js index 7b3358ae9..63501409a 100644 --- a/src/model/TaskState.model.js +++ b/src/model/TaskState.model.js @@ -55,12 +55,6 @@ export const TaskStateUserOrder = [ TaskState.EXPIRED ] -export const JobStates = [ - TaskState.SUBMITTED, - TaskState.RUNNING, - TaskState.SUCCEEDED, - TaskState.FAILED, - TaskState.SUBMIT_FAILED -] +export const TaskStateNames = TaskStateUserOrder.map(({ name }) => name) export default TaskState diff --git a/src/services/mock/json/index.cjs b/src/services/mock/json/index.cjs index b974ceea5..29951a5a6 100644 --- a/src/services/mock/json/index.cjs +++ b/src/services/mock/json/index.cjs @@ -25,6 +25,8 @@ const { LogData } = require('./logData.cjs') const { LogFiles } = require('./logFiles.cjs') const analysisQuery = require('./analysisQuery.json') +const workflows = [workflowOne, ...workflowsMulti] + module.exports = { IntrospectionQuery, taskProxy, @@ -32,7 +34,10 @@ module.exports = { userProfile, LogData, LogFiles, - App: [workflowOne, ...workflowsMulti], - Workflow: workflowOne, + App: workflows, + Workflow ({ workflowId }) { + return workflows.find(({ deltas }) => deltas.id === workflowId) || {} + }, + Test: workflowOne, analysisQuery } diff --git a/src/services/mock/json/workflows/multi.json b/src/services/mock/json/workflows/multi.json index f85a30173..871918d17 100644 --- a/src/services/mock/json/workflows/multi.json +++ b/src/services/mock/json/workflows/multi.json @@ -61,13 +61,13 @@ "added": { "workflow": { "id": "~user/other/multi/run2", - "status": "stopped", - "statusMsg": "not yet run", + "status": "paused", + "statusMsg": "paused", "owner": "user", - "host": "", - "port": 0, + "host": "ncc1701.starfleet.gov", + "port": 43078, "stateTotals": { - "waiting": 0, + "waiting": 2, "expired": 0, "preparing": 0, "submit-failed": 0, @@ -76,9 +76,103 @@ "failed": 0, "succeeded": 0 }, - "latestStateTasks": {}, + "latestStateTasks": { + "failed": [], + "preparing": [], + "submit-failed": [], + "submitted": [], + "running": [] + }, "__typename": "Workflow" }, + "cyclePoints": [ + { + "__typename": "FamilyProxy", + "id": "~user/other/multi/run2//1/root", + "state": "waiting", + "ancestors": [], + "childTasks": [ + { + "id": "~user/other/multi/run2//1/foo", + "__typename": "TaskProxy" + } + ] + }, + { + "__typename": "FamilyProxy", + "id": "~user/other/multi/run2//2/root", + "state": "waiting", + "ancestors": [], + "childTasks": [ + { + "id": "~user/other/multi/run2//2/foo", + "__typename": "TaskProxy" + } + ] + } + ], + "familyProxies": [ + { + "__typename": "FamilyProxy", + "id": "~user/other/multi/run2//1/root", + "state": "waiting", + "ancestors": [], + "childTasks": [ + { + "id": "~user/other/multi/run2//1/foo", + "__typename": "TaskProxy" + } + ] + }, + { + "__typename": "FamilyProxy", + "id": "~user/other/multi/run2//2/root", + "state": "waiting", + "ancestors": [], + "childTasks": [ + { + "id": "~user/other/multi/run2//2/foo", + "__typename": "TaskProxy" + } + ] + } + ], + "taskProxies": [ + { + "id": "~user/other/multi/run2//1/foo", + "state": "waiting", + "isHeld": false, + "isQueued": true, + "isRunahead": false, + "task": { + "meanElapsedTime": 0, + "__typename": "Task" + }, + "firstParent": { + "id": "~user/other/multi/run2//1/root", + "__typename": "FamilyProxy" + }, + "__typename": "TaskProxy" + }, + { + "id": "~user/other/multi/run2//2/foo", + "state": "waiting", + "isHeld": false, + "isQueued": false, + "isRunahead": false, + "task": { + "meanElapsedTime": 0, + "__typename": "Task" + }, + "firstParent": { + "id": "~user/other/multi/run2//2/root", + "__typename": "FamilyProxy" + }, + "__typename": "TaskProxy" + } + ], + "jobs": [], + "edges": [], "__typename": "Added" } } diff --git a/src/services/mock/json/workflows/one.json b/src/services/mock/json/workflows/one.json index 2795cb255..45ea00f2b 100644 --- a/src/services/mock/json/workflows/one.json +++ b/src/services/mock/json/workflows/one.json @@ -1,5 +1,6 @@ { "deltas": { + "id": "~user/one", "added": { "workflow": { "id": "~user/one", diff --git a/src/store/workflows.module.js b/src/store/workflows.module.js index 6de6ec7a4..7d1766e7c 100644 --- a/src/store/workflows.module.js +++ b/src/store/workflows.module.js @@ -253,7 +253,7 @@ function removeTree (state, node, removeParent = true) { * */ function cleanParents (state, node) { let pointer = node - while (pointer.parent) { + while (pointer?.parent) { if (pointer.type !== 'workflow') { // don't prune workflow nodes // (this requires an explicit instruction to do so) diff --git a/src/views/Tree.vue b/src/views/Tree.vue index f191e7c56..90acb3d68 100644 --- a/src/views/Tree.vue +++ b/src/views/Tree.vue @@ -17,29 +17,81 @@ along with this program. If not, see . diff --git a/tests/e2e/specs/aotf.cy.js b/tests/e2e/specs/aotf.cy.js index c771bf71d..e171c7988 100644 --- a/tests/e2e/specs/aotf.cy.js +++ b/tests/e2e/specs/aotf.cy.js @@ -21,7 +21,7 @@ import { } from '@/utils/aotf' import { MUTATIONS -} from '../support/graphql' +} from '$tests/e2e/support/graphql' import { cloneDeep } from 'lodash' function mockApolloClient () { diff --git a/tests/e2e/specs/graphiql.cy.js b/tests/e2e/specs/graphiql.cy.js index 650ad34b9..c5e96ea46 100644 --- a/tests/e2e/specs/graphiql.cy.js +++ b/tests/e2e/specs/graphiql.cy.js @@ -16,7 +16,7 @@ */ /** query used for the graphiql test */ -const query = `query Workflow { +const query = `query Test { workflows { id } diff --git a/tests/e2e/specs/gscan.cy.js b/tests/e2e/specs/gscan.cy.js index e7265970e..fa5d6ec14 100644 --- a/tests/e2e/specs/gscan.cy.js +++ b/tests/e2e/specs/gscan.cy.js @@ -27,7 +27,7 @@ describe('GScan component', () => { .should('have.length', 5) }) - it('should filter by workflow name', () => { + it('filters by workflow name', () => { cy.get('#c-gscan-search-workflows') .type('level') .get('.treeitem:visible') @@ -38,29 +38,34 @@ describe('GScan component', () => { .should('have.length', 0) }) - it('should filter by workflow state', () => { + it('filters by workflow state', () => { cy.get('[data-cy=gscan-filter-btn]') .click() .get('[data-cy="filter workflow state"]') .click() cy.get('.v-select__content') - .contains('.v-list-item', 'paused') + .contains('.v-list-item', 'stopping') .click({ force: true }) .get('.treeitem:visible') .should('have.length', 0) cy.get('.v-select__content') - .contains('.v-list-item', 'running') + .contains('.v-list-item', 'paused') .click({ force: true }) - .get('.treeitem:visible') + .get('.treeitem [data-c-interactive]:visible') .should('have.length', 1) + cy.get('.v-select__content') + .contains('.v-list-item', 'running') + .click({ force: true }) + .get('.treeitem [data-c-interactive]:visible') + .should('have.length', 2) cy.get('.v-select__content') .contains('.v-list-item', 'stopped') .click({ force: true }) - .get('.treeitem:visible') - .should('have.length', 5) + .get('.treeitem [data-c-interactive]:visible') + .should('have.length', 4) }) - it('should filter by task state', () => { + it('filters by task state', () => { cy.get('[data-cy=gscan-filter-btn]') .click() .get('[data-cy="filter task state"]') @@ -72,7 +77,7 @@ describe('GScan component', () => { .should('have.length', 0) }) - it('should filter by workflow name, state, and tasks states', () => { + it('filters by workflow name, state, and tasks states', () => { cy.get('#c-gscan-search-workflows') .type('on') cy.get('[data-cy=gscan-filter-btn]') diff --git a/tests/e2e/specs/mutation.cy.js b/tests/e2e/specs/mutation.cy.js index 43303f873..aa8ea1763 100644 --- a/tests/e2e/specs/mutation.cy.js +++ b/tests/e2e/specs/mutation.cy.js @@ -19,8 +19,8 @@ import { processMutations } from '@/utils/aotf' import { cloneDeep, upperFirst } from 'lodash' import { MUTATIONS -} from '../support/graphql' -import { Deferred } from '../../util' +} from '$tests/e2e/support/graphql' +import { Deferred } from '$tests/util' describe('Mutations component', () => { beforeEach(() => { diff --git a/tests/e2e/specs/tree.cy.js b/tests/e2e/specs/tree.cy.js index 5b407d11f..366b8a06e 100644 --- a/tests/e2e/specs/tree.cy.js +++ b/tests/e2e/specs/tree.cy.js @@ -62,7 +62,7 @@ describe('Tree view', () => { }) it('Displays job details when expanded', () => { // this is testing that there is a margin, not necessarily that the leaf node's triangle is exactly under the node - cy.visit('/#/workspace/one') + cy.visit('/#/tree/one') cy.get('.node-data-task') .contains('eventually_succeeded') .parents('.node') @@ -272,6 +272,34 @@ describe('Tree view', () => { .get('@sleepyTask') .should('be.visible') }) + + it('Does not expand jobs but can collapse them', () => { + cy.visit('/#/tree/one') + .get('[data-cy=expand-all]') + .click() + .get('.node-data-job:first') + .should('not.exist') + cy.get('.node-data-task') + .contains('failed') + .parents('.node') + .find('.node-expand-collapse-button') + .click() + .get('.node-data-job:first') + .should('be.visible') + cy.get('[data-cy=expand-all]') + .click() + // The job should remain expanded + .get('.node-data-job:first') + .should('be.visible') + cy.get('[data-cy=collapse-all]') + .click() + .get('[data-cy=expand-all]') + .click() + // The job should be collapsed now + .get('.node-data-job:first') + .should('not.be.visible') + }) + it('Works when tasks are being filtered', () => { cy.visit('/#/tree/one') cy.get('.node-data-task') diff --git a/tests/e2e/specs/userprofile.cy.js b/tests/e2e/specs/userprofile.cy.js index 57b55fae2..3e5493ed5 100644 --- a/tests/e2e/specs/userprofile.cy.js +++ b/tests/e2e/specs/userprofile.cy.js @@ -133,6 +133,9 @@ describe('User Profile', () => { .get('[data-cy=select-default-view-menu] [role=listbox]') .contains('Table') .click() + // Wait for menu to close before navigation to avoid FF ResizeObserver error + .get('[data-cy=select-default-view-menu]') + .should('not.exist') cy.visit('/#/workspace/one') .get('[data-cy=workspace-view] .c-table') .should('be.visible') diff --git a/tests/e2e/specs/workflowservice.cy.js b/tests/e2e/specs/workflowservice.cy.js index 8a2675ba4..2c15e27c4 100644 --- a/tests/e2e/specs/workflowservice.cy.js +++ b/tests/e2e/specs/workflowservice.cy.js @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -import { Deferred } from '../../util' +import { Deferred } from '$tests/util' // Tests for the WorkflowService subscriptions. Not necessarily GraphQL subscriptions! diff --git a/tests/unit/components/cylc/gscan/gscan.vue.spec.js b/tests/unit/components/cylc/gscan/gscan.vue.spec.js index 191e475bf..f0bb7c54d 100644 --- a/tests/unit/components/cylc/gscan/gscan.vue.spec.js +++ b/tests/unit/components/cylc/gscan/gscan.vue.spec.js @@ -15,26 +15,38 @@ * along with this program. If not, see . */ +import { mount } from '@vue/test-utils' import { createStore } from 'vuex' +import { createVuetify } from 'vuetify' import storeOptions from '@/store/options' +import GScan from '@/components/cylc/gscan/GScan.vue' +import CylcObjectPlugin from '@/components/cylc/cylcObject/plugin' import { WorkflowState, WorkflowStateOrder } from '@/model/WorkflowState.model' import TaskState from '@/model/TaskState.model' -import GScan from '@/components/cylc/gscan/GScan.vue' import { getWorkflowTreeSortValue, sortedWorkflowTree } from '@/components/cylc/gscan/sort.js' -import { - filterHierarchically -} from '@/components/cylc/gscan/filters' import { TEST_TREE, listTree } from './utils' +import { getIDMap } from '$tests/util' + +const vuetify = createVuetify() + +/** + * Helper function to run filtering. + */ +function filterNodes (wrapper, filteredOutNodesCache) { + for (const node of wrapper.vm.workflows) { + wrapper.vm.filterNode(node, filteredOutNodesCache) + } +} describe('GScan component', () => { const store = createStore(storeOptions) @@ -44,7 +56,7 @@ describe('GScan component', () => { beforeEach(resetState) describe('Sorting', () => { - it('should set workflow sort order by status', () => { + it('sets workflow sort order by status', () => { // for each worflow state ... for (const workflowState of WorkflowState) { // ... except ERROR @@ -81,7 +93,8 @@ describe('GScan component', () => { ).to.equal(WorkflowStateOrder.get(workflowState.name)) } }) - it('should sort workflows', () => { + + it('sorts workflows', () => { // it should sort by status then name expect( listTree(sortedWorkflowTree(TEST_TREE)) @@ -92,150 +105,158 @@ describe('GScan component', () => { }) describe('Filters', () => { - it("shouldn't filter out workflows incorrectly", () => { - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - // don't filter by name - null, - // filter for all workflow states - [...WorkflowStateOrder.keys()], - // filter for all task states - [] - ) - ) - ).to.deep.equal(['~u/b', '~u/c', '~u/a/x1', '~u/a/x2']) + const mountFunction = (options) => mount(GScan, { + global: { + plugins: [vuetify, CylcObjectPlugin], + }, + props: { + workflowTree: TEST_TREE, + isLoading: false, + }, + ...options }) - it('should filter by workflow state', () => { - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - // don't filter by name - null, - [WorkflowState.RUNNING.name], - // filter for all task states - [] - ) - ) - ).to.deep.equal(['~u/c']) - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - // don't filter by name - null, - [WorkflowState.STOPPING.name], - // filter for all task states - [] - ) - ) - ).to.deep.equal(['~u/b']) - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - // don't filter by name - null, - [WorkflowState.STOPPED.name], - // filter for all task states - [] - ) - ) - ).to.deep.equal(['~u/a/x1', '~u/a/x2']) - }) - it('should filter by workflow name', () => { - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - 'x', - // filter for all workflow states - [...WorkflowStateOrder.keys()], - // filter for all task states - [] - ) - ) - ).to.deep.equal(['~u/a/x1', '~u/a/x2']) - // check it isn't matching the user name - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - 'u', - // filter for all workflow states - [...WorkflowStateOrder.keys()], - // filter for all task states - [] - ) - ) - ).to.deep.equal([]) + + it('has null filterState when filters are empty', async () => { + const wrapper = mountFunction() + expect(wrapper.vm.searchWorkflows).toEqual('') + expect(wrapper.vm.filters).toEqual({ + 'workflow state': [], + 'task state': [], + }) + await wrapper.setData({ + searchWorkflows: ' ', + filters: { + 'workflow state': [], + 'task state': [], + } + }) + expect(wrapper.vm.filterState).toBeNull() }) - it('should filter by workflow state totals', () => { - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - null, - // filter for all workflow states - [...WorkflowStateOrder.keys()], - // filter for all task states - [TaskState.RUNNING.name] - ) - ) - ).to.deep.equal(['~u/b']) - expect( - listTree( - filterHierarchically( - sortedWorkflowTree(TEST_TREE), - null, - // filter for all workflow states - [...WorkflowStateOrder.keys()], - // filter for all task states - [TaskState.SUBMITTED.name] - ) - ) - ).to.deep.equal(['~u/c']) + + it("shouldn't filter out workflows incorrectly", async () => { + const wrapper = mountFunction() + const filteredOutNodesCache = new Map() + // filter for all workflow states + await wrapper.setData({ + filters: { 'workflow state': WorkflowStateOrder.keys() }, + }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': false, + '~u/a/x1': false, + '~u/a/x2': false, + '~u/b': false, + '~u/c': false, + }) }) - }) - describe('Toggle items values', () => { - it('should toggle items values to true', () => { - const items = [ - { - model: false + it('filters by workflow state', async () => { + const wrapper = mountFunction() + const filteredOutNodesCache = new Map() + + await wrapper.setData({ + filters: { 'workflow state': [WorkflowState.RUNNING.name] }, + }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': true, + '~u/a/x1': true, + '~u/a/x2': true, + '~u/b': true, + '~u/c': false, + }) + + await wrapper.setData({ + filters: { + 'workflow state': [ + WorkflowState.STOPPING.name, + WorkflowState.STOPPED.name, + ] }, - { - model: false - } - ] - GScan.methods.toggleItemsValues(items) - expect(items.every(item => item.model)) + }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': false, + '~u/a/x1': false, + '~u/a/x2': false, + '~u/b': false, + '~u/c': true, + }) }) - it('should toggle items values to false', () => { - const items = [ - { - model: true - }, - { - model: true - } - ] - GScan.methods.toggleItemsValues(items) - expect(!items.every(item => item.model)) + + it('filters by workflow name', async () => { + const wrapper = mountFunction() + const filteredOutNodesCache = new Map() + + await wrapper.setData({ searchWorkflows: 'x' }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': false, + '~u/a/x1': false, + '~u/a/x2': false, + '~u/b': true, + '~u/c': true, + }) + + await wrapper.setData({ searchWorkflows: 'u' }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': true, + '~u/a/x1': true, + '~u/a/x2': true, + '~u/b': true, + '~u/c': true, + }) }) - it('should toggle items values to false (mixed values)', () => { - const items = [ - { - model: true + + it('filters by task state', async () => { + const wrapper = mountFunction() + const filteredOutNodesCache = new Map() + + await wrapper.setData({ + filters: { 'task state': [TaskState.RUNNING.name] } + }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': true, + '~u/a/x1': true, + '~u/a/x2': true, + '~u/b': false, + '~u/c': true, + }) + + await wrapper.setData({ + filters: { 'task state': [TaskState.SUBMITTED.name] } + }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': true, + '~u/a/x1': true, + '~u/a/x2': true, + '~u/b': true, + '~u/c': false, + }) + }) + + it('filters by workflow name & workflow state & task state', async () => { + const wrapper = mountFunction() + const filteredOutNodesCache = new Map() + + await wrapper.setData({ + searchWorkflows: 'a', + filters: { + 'workflow state': [WorkflowState.STOPPED.name], + 'task state': [TaskState.SUBMIT_FAILED.name], }, - { - model: false - } - ] - GScan.methods.toggleItemsValues(items) - expect(!items.every(item => item.model)) + }) + filterNodes(wrapper, filteredOutNodesCache) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~u/a': false, + '~u/a/x1': false, + '~u/a/x2': true, + '~u/b': true, + '~u/c': true, + }) }) }) }) diff --git a/tests/unit/components/cylc/gscan/utils.js b/tests/unit/components/cylc/gscan/utils.js index 0886442d5..96765575b 100644 --- a/tests/unit/components/cylc/gscan/utils.js +++ b/tests/unit/components/cylc/gscan/utils.js @@ -17,52 +17,55 @@ import { WorkflowState } from '@/model/WorkflowState.model' import TaskState from '@/model/TaskState.model' +import { Tokens } from '@/utils/uid' -const RUNNING_STATE_TOTALS = {} -RUNNING_STATE_TOTALS[TaskState.RUNNING.name] = 1 -RUNNING_STATE_TOTALS[TaskState.SUBMITTED.name] = 0 +const RUNNING_STATE_TOTALS = { + [TaskState.RUNNING.name]: 1, + [TaskState.SUBMITTED.name]: 0, +} + +const SUBMITTED_STATE_TOTALS = { + [TaskState.RUNNING.name]: 0, + [TaskState.SUBMITTED.name]: 1, +} -const SUBMITTED_STATE_TOTALS = {} -SUBMITTED_STATE_TOTALS[TaskState.RUNNING.name] = 0 -SUBMITTED_STATE_TOTALS[TaskState.SUBMITTED.name] = 1 +const SUBMIT_FAILED_STATE_TOTALS = { + [TaskState.SUBMIT_FAILED.name]: 1, +} export const TEST_TREE = { children: [ { id: '~u', type: 'user', - tokens: { - user: 'u' - }, + tokens: new Tokens('u'), children: [ { id: '~u/a', name: 'a', type: 'workflow-part', - tokens: { - user: 'u', - workflow: 'a' - }, + tokens: new Tokens('~u/a'), children: [ { id: '~u/a/x1', name: 'x1', type: 'workflow', - tokens: { - user: 'u', - workflow: 'a/x1' - }, - node: { status: WorkflowState.STOPPED.name } + tokens: new Tokens('~u/a/x1'), + node: { + status: WorkflowState.STOPPED.name, + stateTotals: SUBMIT_FAILED_STATE_TOTALS, + latestStateTasks: [], + } }, { id: '~u/a/x2', name: 'x2', type: 'workflow', - tokens: { - user: 'u', - workflow: 'a/x2' - }, - node: { status: WorkflowState.STOPPED.name } + tokens: new Tokens('~u/a/x2'), + node: { + status: WorkflowState.STOPPED.name, + latestStateTasks: [], + } } ] }, @@ -70,26 +73,22 @@ export const TEST_TREE = { id: '~u/b', name: 'b', type: 'workflow', - tokens: { - user: 'u', - workflow: 'b' - }, + tokens: new Tokens('~u/b'), node: { status: WorkflowState.STOPPING.name, - stateTotals: RUNNING_STATE_TOTALS + stateTotals: RUNNING_STATE_TOTALS, + latestStateTasks: [], } }, { id: '~u/c', name: 'c', type: 'workflow', - tokens: { - user: 'u', - workflow: 'c' - }, + tokens: new Tokens('~u/c'), node: { status: WorkflowState.RUNNING.name, - stateTotals: SUBMITTED_STATE_TOTALS + stateTotals: SUBMITTED_STATE_TOTALS, + latestStateTasks: [], } } ] @@ -97,18 +96,25 @@ export const TEST_TREE = { ] } -export function listTree (gscanTree) { - // return a flat list of workflow ids for all workflow nodes in the - // tree, preserving sort order +/** + * Return a flat list of workflow ids for all workflow nodes in the + * tree, preserving sort order + * + * @param {Object} tree + * @param {boolean} filter - whether to remove items that have been filtered out + */ +export function listTree (tree, filter = false) { const ret = [] - const stack = [...gscanTree] + const stack = [...tree] let item while (stack.length) { item = stack.splice(0, 1)[0] - if (['workflow-part', 'user'].includes(item.type)) { - stack.push(...item.children) - } else if (item.type === 'workflow') { - ret.push(item.id) + if (!filter || !item.filteredOut) { + if (['workflow-part', 'user'].includes(item.type)) { + stack.push(...item.children) + } else if (item.type === 'workflow') { + ret.push(item.id) + } } } return ret diff --git a/tests/unit/components/cylc/tree/tree.data.js b/tests/unit/components/cylc/tree/tree.data.js index fd50167d5..a728cb9d3 100644 --- a/tests/unit/components/cylc/tree/tree.data.js +++ b/tests/unit/components/cylc/tree/tree.data.js @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +import { Tokens } from '@/utils/uid' + /* * Test data for Tree component tests. */ @@ -216,6 +218,7 @@ const simpleWorkflowTree4Nodes = [ { id: '~user/workflow1', name: 'workflow1', + tokens: new Tokens('~user/workflow1'), type: 'workflow', node: { __typename: 'Workflow', @@ -228,6 +231,7 @@ const simpleWorkflowTree4Nodes = [ { id: '~user/workflow1//20100101T0000Z', name: '20100101T0000Z', + tokens: new Tokens('~user/workflow1//20100101T0000Z'), type: 'cycle', node: { __typename: 'CyclePoint', @@ -238,6 +242,7 @@ const simpleWorkflowTree4Nodes = [ { id: '~user/workflow1//20100101T0000Z/root', name: 'root', + tokens: new Tokens('~user/workflow1//20100101T0000Z/root'), type: 'family', node: { state: 'failed' @@ -246,16 +251,17 @@ const simpleWorkflowTree4Nodes = [ { id: '~user/workflow1//20100101T0000Z/foo', name: 'foo', + tokens: new Tokens('~user/workflow1//20100101T0000Z/foo'), type: 'task', node: { __typename: 'TaskProxy', state: 'failed' }, - expanded: false, children: [ { id: '~user/workflow1//20100101T0000Z/foo/01', name: '01', + tokens: new Tokens('~user/workflow1//20100101T0000Z/foo/01'), type: 'job', node: { __typename: 'Job', diff --git a/tests/unit/components/cylc/tree/tree.vue.spec.js b/tests/unit/components/cylc/tree/tree.vue.spec.js index c8e21d82e..10dcf7a81 100644 --- a/tests/unit/components/cylc/tree/tree.vue.spec.js +++ b/tests/unit/components/cylc/tree/tree.vue.spec.js @@ -17,307 +17,63 @@ // we mount the tree to include the TreeItem component and other vuetify children components import { mount } from '@vue/test-utils' +import { vi } from 'vitest' import { createVuetify } from 'vuetify' -import sinon from 'sinon' +import { cloneDeep } from 'lodash' import Tree from '@/components/cylc/tree/Tree.vue' import { simpleWorkflowTree4Nodes } from './tree.data' import CylcObjectPlugin from '@/components/cylc/cylcObject/plugin' -import cloneDeep from 'lodash/cloneDeep' -import WorkflowService from '@/services/workflow.service' -const $eventBus = { - emit () {} -} -const $workflowService = sinon.createStubInstance(WorkflowService) const vuetify = createVuetify() describe('Tree component', () => { - /** - * @param options - * @returns {Wrapper} - */ - const mountFunction = (options) => mount(Tree, { + const mountFunction = (props) => mount(Tree, { global: { plugins: [vuetify, CylcObjectPlugin], - mocks: { - $eventBus, - $workflowService - } }, - ...options + props: { + workflows: cloneDeep(simpleWorkflowTree4Nodes), + autoStripTypes: ['workflow'], + filterState: null, + ...props, + } }) - it('should display the tree with valid data', () => { + it.each([ + { autoStripTypes: [], expected: simpleWorkflowTree4Nodes }, + { autoStripTypes: ['workflow'], expected: simpleWorkflowTree4Nodes[0].children }, + ])('auto strips $autoStripTypes', ({ autoStripTypes, expected }) => { const wrapper = mountFunction({ - props: { - workflows: simpleWorkflowTree4Nodes[0].children - } - }) - expect(wrapper.props().workflows[0].node.__typename).to.equal('CyclePoint') - expect(wrapper.find('div')).to.not.equal(null) - }) - describe('Filter', () => { - describe('Default', () => { - it('should not filter by name or state by default', () => { - const wrapper = mountFunction({ - props: { - workflows: simpleWorkflowTree4Nodes[0].children - } - }) - expect(wrapper.vm.tasksFilter).to.deep.equal({}) - }) - }) - }) - describe('Caches', () => { - /** - * Create a tree item for tests with the caches. - * @param {string} id - node ID - * @param {boolean} expanded - whether node is expanded or not - * @returns {{isExpanded: *, $props: {node: {id: *}}}} - */ - const createTreeItem = (id, expanded) => { - return { - isExpanded: expanded, - $props: { - node: { - id - } - } - } - } - it('should all be initialized to empty caches', () => { - const wrapper = mountFunction({ - props: { - workflows: [] - } - }) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(0) - expect(wrapper.vm.expandedCache.size).to.equal(0) - }) - it('should add to the tree item cache', () => { - const wrapper = mountFunction({ - props: { - workflows: [] - } - }) - const treeItem = createTreeItem('1', false) - wrapper.vm.onTreeItemCreated(treeItem) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(1) - expect(wrapper.vm.expandedCache.size).to.equal(0) - }) - it('should remove from the tree item cache', () => { - const wrapper = mountFunction({ - props: { - workflows: [] - } - }) - const treeItem = createTreeItem('1', false) - wrapper.vm.onTreeItemCreated(treeItem) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(1) - wrapper.vm.onTreeItemDestroyed(treeItem) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(0) - }) - it('should add to the expanded cache', () => { - const wrapper = mountFunction({ - props: { - workflows: [] - } - }) - const treeItem = createTreeItem('1', true) - wrapper.vm.onTreeItemCreated(treeItem) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(1) - expect(wrapper.vm.expandedCache.size).to.equal(1) - }) - it('should remove from the expanded cache', () => { - const wrapper = mountFunction({ - props: { - workflows: [] - } - }) - const treeItem = createTreeItem('1', true) - wrapper.vm.onTreeItemCreated(treeItem) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(1) - expect(wrapper.vm.expandedCache.size).to.equal(1) - wrapper.vm.onTreeItemCollapsed(treeItem) - expect(Object.keys(wrapper.vm.treeItemCache).length).to.equal(1) - expect(wrapper.vm.expandedCache.size).to.equal(0) + autoStripTypes, }) + expect(wrapper.vm.rootChildren).toEqual(expected) }) - describe('Expand-Collapse', () => { - // Three collections, one with mixed (expanded/collapsed), - // one with all items collapsed, and one with all items expanded. - // Enough to cover the possible test scenarios for the - // expand-collapse toggle functions. - const mixed = { - '~user/workflow': { - id: '~user/workflow', - type: 'workflow', - isExpanded: false - }, - '~user/workflow//1': { - id: '~user/workflow//1', - type: 'cyclepoint', - isExpanded: true - } - } - const allCollapsed = { - '~user/workflow': { - id: '~user/workflow', - type: 'workflow', - isExpanded: false - }, - '~user/workflow//1': { - id: '~user/workflow//1', - type: 'cyclepoint', - isExpanded: false - } - } - const allExpanded = { - '~user/workflow': { - id: '~user/workflow', - type: 'workflow', - isExpanded: true - }, - '~user/workflow//1': { - id: '~user/workflow//1', - type: 'cyclepoint', - isExpanded: true - } - } - const filterWorkflows = (item) => item.type === 'workflow' - const filterCyclepoints = (item) => item.type === 'cyclepoint' - const filterTaskProxies = (item) => item.type === 'task-proxy' - it.each([ - // everything is expanded - { - filter: null, - items: mixed, - expectedExpandedItems: 2 - }, - { - filter: null, - items: allExpanded, - expectedExpandedItems: 2 - }, - { - filter: null, - items: allCollapsed, - expectedExpandedItems: 2 - }, - // will expand the collapsed workflow (found with the filter) - { - filter: filterWorkflows, - items: allCollapsed, - expectedExpandedItems: 1 - }, - // workflow will stay collapsed - { - filter: filterCyclepoints, - items: allCollapsed, - expectedExpandedItems: 1 - }, - // filter won't find any task-proxies, so everything is still collapsed - { - filter: filterTaskProxies, - items: allCollapsed, - expectedExpandedItems: 0 - }, - ])('should expand items %#', ({ filter, items, expectedExpandedItems }) => { - // we clone the test data structures as the function mutates the objects - const wrapper = mountFunction({ - props: { - workflows: [], - expandCollapseToggle: true - }, - data () { - return { - treeItemCache: cloneDeep(items), - } - } - }) - const { treeItemCache } = wrapper.vm - - wrapper.vm.expandAll(filter) - expect(wrapper.vm.expanded).to.equal(true) - - const collection = filter ? Object.values(treeItemCache).filter(filter) : Object.values(treeItemCache) - for (const item of collection) { - expect(item.isExpanded).to.equal(true) - } - expect( - Object.values(treeItemCache).filter(item => item.isExpanded).length, - `Failed case: ${JSON.stringify(items)}` - ).to.equal(expectedExpandedItems) - }) - - it.each([ - // everything is collapsed - { - filter: null, - items: cloneDeep(mixed), - expectedCollapsedItems: 2 - }, - { - filter: null, - items: cloneDeep(allExpanded), - expectedCollapsedItems: 2 - }, - { - filter: null, - items: cloneDeep(allCollapsed), - expectedCollapsedItems: 2 - }, - // will collapse the expanded workflow (found with the filter) - { - filter: filterWorkflows, - items: cloneDeep(allExpanded), - expectedCollapsedItems: 1 - }, - // workflow will stay expanded - { - filter: filterCyclepoints, - items: cloneDeep(allExpanded), - expectedCollapsedItems: 1 - }, - // filter won't find any task-proxies, so everything is still expanded - { - filter: filterTaskProxies, - items: cloneDeep(allExpanded), - expectedCollapsedItems: 0 - } - ])('should collapse items %#', ({ filter, items, expectedCollapsedItems }) => { - // we clone the test data structures as the function mutates the objects + describe('Filter', () => { + it('only runs filtering when applicable', async () => { + const nodeFilter = vi.fn() const wrapper = mountFunction({ - props: { - workflows: [], - expandCollapseToggle: true - }, - data () { - return { - treeItemCache: items, - } - } + nodeFilterFunc: nodeFilter, + filterState: {}, }) - // the collapseAll rely on expandedCache being filled correctly - Object.values(items).forEach(item => { - if (item.isExpanded) { - wrapper.vm.expandedCache.add(item) - } + // Does not run filtering when filterState changes & is falsy + await wrapper.setProps({ filterState: null }) + expect(nodeFilter).not.toHaveBeenCalled() + // Runs filtering when filterState changes & is truthy + await wrapper.setProps({ filterState: {} }) + expect(nodeFilter.mock.calls).toEqual([ + [simpleWorkflowTree4Nodes[0].children[0], wrapper.vm.filteredOutNodesCache], + ]) + nodeFilter.mockClear() + // Runs filtering when tree changes + const newWorkflows = cloneDeep(simpleWorkflowTree4Nodes) + newWorkflows[0].children[0].node.state = 'frobnicated' + await wrapper.setProps({ + workflows: newWorkflows, }) - const treeItemCache = wrapper.vm.treeItemCache - - wrapper.vm.collapseAll(filter) - expect(wrapper.vm.expanded).to.equal(filter !== null) - - const collection = filter ? Object.values(treeItemCache).filter(filter) : Object.values(treeItemCache) - for (const item of collection) { - expect(item.isExpanded).to.equal(false) - } - expect( - Object.values(treeItemCache).filter(item => !item.isExpanded).length, - `Failed case: ${JSON.stringify(items)}` - ).to.equal(expectedCollapsedItems) + expect(nodeFilter.mock.calls).toEqual([ + [newWorkflows[0].children[0], wrapper.vm.filteredOutNodesCache], + ]) }) }) }) diff --git a/tests/unit/components/cylc/tree/treeitem.vue.spec.js b/tests/unit/components/cylc/tree/treeitem.vue.spec.js index 0a653a985..d0ce28de8 100644 --- a/tests/unit/components/cylc/tree/treeitem.vue.spec.js +++ b/tests/unit/components/cylc/tree/treeitem.vue.spec.js @@ -66,36 +66,35 @@ describe('TreeItem component', () => { plugins: [createVuetify(), CylcObjectPlugin], mock: { $workflowService, $eventBus } }, - props: { - node: simpleWorkflowNode - }, ...options }) it('should display the treeitem with valid data', () => { - const wrapper = mountFunction() + const wrapper = mountFunction({ + props: { + node: simpleWorkflowNode, + filteredOutNodesCache: new WeakMap(), + }, + }) expect(wrapper.props().node.node.__typename).to.equal('Workflow') - expect(wrapper.vm.$data.filtered).to.equal(true) }) describe('expanded', () => { // using simpleJobNode as it has only one child so it is easier/quicker to test - it('should expand nodes when configured', () => { - let wrapper = mountFunction({ - props: { - node: simpleCyclepointNode, - autoExpandTypes: ['cycle'] - } - }) - expect(wrapper).to.be.expanded() - - wrapper = mountFunction({ + it.each([ + [simpleCyclepointNode, true], + [simpleTaskNode, false], + ])('should expand nodes when configured %#', (node, expected) => { + const wrapper = mountFunction({ props: { - node: simpleTaskNode, + node, + filteredOutNodesCache: new WeakMap(), autoExpandTypes: ['cycle'] } }) - expect(wrapper).to.not.be.expanded() + expected + ? expect(wrapper).to.be.expanded() + : expect(wrapper).to.not.be.expanded() }) }) @@ -103,6 +102,7 @@ describe('TreeItem component', () => { const wrapper = mountFunction({ props: { node: simpleTaskNode, + filteredOutNodesCache: new WeakMap(), } }) expect(wrapper).to.not.be.expanded() @@ -126,6 +126,7 @@ describe('TreeItem component', () => { const wrapper = mountFunction({ props: { node: simpleWorkflowNode, + filteredOutNodesCache: new WeakMap(), }, data: () => ({ manuallyExpanded, @@ -136,113 +137,6 @@ describe('TreeItem component', () => { .map((vm) => vm.props().node.node.__typename) ).to.deep.equal(expected) }) - - // }) - // describe('mixin', () => { - // const sortTestsData = [ - // // invalid values - // { - // args: { - // type: '', - // children: [] - // }, - // expected: [] - // }, - // { - // args: { - // type: null, - // children: null - // }, - // expected: null - // }, - // // workflow children (cycle points) are sorted by ID in descending order - // { - // args: { - // type: 'workflow', - // children: [ - // { id: 'workflow//1' }, - // { id: 'workflow//2' } - // ] - // }, - // expected: [ - // { id: 'workflow//2' }, - // { id: 'workflow//1' } - // ] - // }, - // // cycle point children (family proxies and task proxies) are sorted by type in ascending order, and then name in ascending order - // { - // args: { - // type: 'cyclepoint', - // children: [ - // { node: { name: 'foo' }, type: 'task-proxy' }, - // { node: { name: 'FAM1' }, type: 'family-proxy' }, - // { node: { name: 'bar' }, type: 'task-proxy' } - // ] - // }, - // expected: [ - // { node: { name: 'FAM1' }, type: 'family-proxy' }, - // { node: { name: 'bar' }, type: 'task-proxy' }, - // { node: { name: 'foo' }, type: 'task-proxy' } - // ] - // }, - // // family proxy children (family proxies and task proxies) are sorted by type in ascending order, and then name in ascending order - // { - // args: { - // type: 'family-proxy', - // children: [ - // { node: { name: 'foo' }, type: 'task-proxy' }, - // { node: { name: 'FAM1' }, type: 'family-proxy' }, - // { node: { name: 'bar' }, type: 'task-proxy' } - // ] - // }, - // expected: [ - // { node: { name: 'FAM1' }, type: 'family-proxy' }, - // { node: { name: 'bar' }, type: 'task-proxy' }, - // { node: { name: 'foo' }, type: 'task-proxy' } - // ] - // }, - // { - // args: { - // type: 'family-proxy', - // children: [ - // { node: { name: 'f01' }, type: 'task-proxy' }, - // { node: { name: 'f1' }, type: 'task-proxy' }, - // { node: { name: 'f10' }, type: 'task-proxy' }, - // { node: { name: 'f0' }, type: 'task-proxy' }, - // { node: { name: 'f2' }, type: 'task-proxy' } - // ] - // }, - // expected: [ - // { node: { name: 'f0' }, type: 'task-proxy' }, - // { node: { name: 'f01' }, type: 'task-proxy' }, - // { node: { name: 'f1' }, type: 'task-proxy' }, - // { node: { name: 'f2' }, type: 'task-proxy' }, - // { node: { name: 'f10' }, type: 'task-proxy' } - // ] - // }, - // // task proxy children (jobs) are sorted by job submit number in descending order - // { - // args: { - // type: 'task-proxy', - // children: [ - // { node: { submitNum: '2' } }, - // { node: { submitNum: '1' } }, - // { node: { submitNum: '3' } } - // ] - // }, - // expected: [ - // { node: { submitNum: '3' } }, - // { node: { submitNum: '2' } }, - // { node: { submitNum: '1' } } - // ] - // } - // ] - // sortTestsData.forEach((test) => { - // it('should order elements correctly', () => { - // const sorted = treeitem.methods.sortedChildren(test.args.type, test.args.children) - // expect(sorted).to.deep.equal(test.expected) - // }) - // }) }) }) @@ -259,6 +153,7 @@ describe('GScanTreeItem', () => { const wrapper = mountFunction({ props: { node: flattenWorkflowParts(stateTotalsTestWorkflowNodes), + filteredOutNodesCache: new WeakMap(), } }) it('combines all descendant tasks', () => { @@ -284,6 +179,7 @@ describe('GScanTreeItem', () => { node: { type: 'barbenheimer', }, + filteredOutNodesCache: new WeakMap(), }, shallow: true, }) @@ -296,6 +192,7 @@ describe('GScanTreeItem', () => { type: 'workflow', tokens: { workflow: 'a/b/c' } }, + filteredOutNodesCache: new WeakMap(), }, shallow: true, }) diff --git a/tests/unit/views/tree.vue.spec.js b/tests/unit/views/tree.vue.spec.js new file mode 100644 index 000000000..38f5cc423 --- /dev/null +++ b/tests/unit/views/tree.vue.spec.js @@ -0,0 +1,139 @@ +/* Copyright (C) NIWA & British Crown (Met Office) & Contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ + +import { mount } from '@vue/test-utils' +import { createStore } from 'vuex' +import { createVuetify } from 'vuetify' +import sinon from 'sinon' +import storeOptions from '@/store/options' +import Tree from '@/views/Tree.vue' +import User from '@/model/User.model' +import WorkflowService from '@/services/workflow.service' +import CylcObjectPlugin from '@/components/cylc/cylcObject/plugin' +import { Tokens } from '@/utils/uid' +import { getIDMap } from '$tests/util' + +const $workflowService = sinon.createStubInstance(WorkflowService) +const vuetify = createVuetify() + +const expandID = (id) => ({ + id, + tokens: new Tokens(id), + node: {}, +}) + +const workflowNode = { + ...expandID('~user/workflow1'), + type: 'workflow', + children: [ + { + ...expandID('~user/workflow1//1'), + type: 'cycle', + children: [], + familyTree: [ + { + ...expandID('~user/workflow1//1/root'), + type: 'family', + children: [ + { + ...expandID('~user/workflow1//1/FAM'), + type: 'family', + children: [ + { + ...expandID('~user/workflow1//1/foo'), + type: 'task', + node: { state: 'failed' }, + children: [ + { + ...expandID('~user/workflow1//1/foo/1'), + type: 'job', + }, + ] + }, + { + ...expandID('~user/workflow1//1/bar'), + type: 'task', + node: { state: 'waiting' }, + children: [], + }, + ], + }, + ], + }, + ], + }, + ], +} + +describe('Tree view', () => { + let mountFunction + beforeEach(() => { + const store = createStore(storeOptions) + const user = new User('cylc', [], new Date(), true, 'localhost', 'owner') + store.commit('user/SET_USER', user) + mountFunction = (options) => mount(Tree, { + global: { + plugins: [vuetify, CylcObjectPlugin, store], + mocks: { + $workflowService + } + }, + props: { + workflowName: 'workflow1', + }, + ...options + }) + }) + + describe('Filter', () => { + it.each([ + {}, + { id: null, states: null }, + { id: ' ', states: [] } + ])('has null filterState when filters are empty: %o', async (tasksFilter) => { + const wrapper = mountFunction() + expect(wrapper.vm.tasksFilter).toEqual({ + id: null, + states: null, + }) + await wrapper.setData({ tasksFilter }) + expect(wrapper.vm.filterState).toBeNull() + }) + + it.each([ + { tasksFilter: { id: 'foo' }, filteredOut: false }, + { tasksFilter: { states: ['failed'] }, filteredOut: false }, + { tasksFilter: { id: 'foo', states: ['failed'] }, filteredOut: false }, + + { tasksFilter: { id: 'asdf' }, filteredOut: true }, + { tasksFilter: { states: ['running'] }, filteredOut: true }, + { tasksFilter: { id: 'foo', states: ['running'] }, filteredOut: true }, + { tasksFilter: { id: 'asdf', states: ['failed'] }, filteredOut: true }, + ])('filters by $tasksFilter', async ({ tasksFilter, filteredOut }) => { + const wrapper = mountFunction() + await wrapper.setData({ tasksFilter }) + expect(wrapper.vm.filterState).toMatchObject(tasksFilter) + const filteredOutNodesCache = new Map() + expect(wrapper.vm.filterNode(workflowNode, filteredOutNodesCache)).toEqual(!filteredOut) + expect(getIDMap(filteredOutNodesCache)).toEqual({ + '~user/workflow1': filteredOut, + '~user/workflow1//1': filteredOut, + '~user/workflow1//1/FAM': filteredOut, + '~user/workflow1//1/foo': filteredOut, + '~user/workflow1//1/bar': true, // always filtered out + }) + }) + }) +}) diff --git a/tests/util.js b/tests/util.js index 811b9b756..212cdc81b 100644 --- a/tests/util.js +++ b/tests/util.js @@ -30,3 +30,17 @@ export class Deferred { }) } } + +/** + * Convert filteredOutNodesCache to a simpler object for easier comparison. + * + * Used by Tree view & GScan tests. + * + * @param {Map} filteredOutNodesCache + * @returns {Object} + */ +export function getIDMap (filteredOutNodesCache) { + return Object.fromEntries( + Array.from(filteredOutNodesCache.entries(), ([node, val]) => [node.id, val]) + ) +} diff --git a/vite.config.js b/vite.config.js index 33f8a858a..d47c23b1f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -50,6 +50,7 @@ export default defineConfig(({ mode }) => { resolve: { alias: { '@': path.resolve(__dirname, './src'), + $tests: path.resolve(__dirname, './tests'), react: 'preact/compat', 'react-dom': 'preact/compat', } @@ -98,6 +99,7 @@ export default defineConfig(({ mode }) => { environment: 'jsdom', globals: true, // auto-import `describe`, `it`, `beforeEach` etc. setupFiles: ['./tests/unit/setup.js'], + restoreMocks: true, deps: { // inline vuetify to prevent 'TypeError: Unknown file extension ".css" inline: ['vuetify']