Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2632 (2024-01-31 09:36:01)

refactor: Create Task.isBlocked() and Task.isBlocking()
<!--- Provide a general summary of your changes in the Title above -->

# Description

Create methods `Task.isBlocked()` and `Task.isBlocking()`.

## Motivation and Context

These will be the foundations of the dependencies-related filtering code - and will replace the current implementations of these filters:

- `is not blocked`
- `is blocking`

Their logic - see the JSdocs in new methods - arises from a circa 1.5 hour long discussion with @ilandikov yesterday, doing exploratory testing of the current filters, and how they interact with statuses.

Later these methods will be documented for use with custom searches.

## How has this been tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

A new approval test that allows visualisation of the behaviour.

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/src/Task/Task.ts b/src/Task/Task.ts
index 920583e3..ec8fcc4d 100644
--- a/src/Task/Task.ts
+++ b/src/Task/Task.ts
@@ -450,6 +450,61 @@ export class Task {
         );
     }
 
+    /**
+     * A task is treated as blocked if it depends on any existing task ids on tasks that are TODO or IN_PROGRESS.
+     *
+     * 'Done' tasks (with status DONE, CANCELLED or NON_TASK) are never blocked.
+     * Only direct dependencies are considered.
+     * @param allTasks - all the tasks in the vault. In custom queries, this is available via query.allTasks.
+     */
+    public isBlocked(allTasks: Task[]) {
+        if (this.blockedBy.length === 0) {
+            return false;
+        }
+
+        if (this.isDone) {
+            return false;
+        }
+
+        for (const depId of this.blockedBy) {
+            const depTask = allTasks.find((task) => task.id === depId && !task.isDone);
+            if (!depTask) {
+                // There is no not-done task with this id.
+                continue;
+            }
+
+            // We found a not-done task that this depends on, meaning this one is blocked:
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * A Task is blocking if there is any other not-done task blockedBy value with its id.
+     *
+     * 'Done' tasks (with status DONE, CANCELLED or NON_TASK) are never blocking.
+     * Only direct dependencies are considered.
+     * @param allTasks - all the tasks in the vault. In custom queries, this is available via query.allTasks.
+     */
+    public isBlocking(allTasks: Task[]) {
+        if (this.id === '') {
+            return false;
+        }
+
+        if (this.isDone) {
+            return false;
+        }
+
+        return allTasks.some((task) => {
+            if (task.isDone) {
+                return false;
+            }
+
+            return task.blockedBy.includes(this.id);
+        });
+    }
+
     /**
      * Return the number of the Task's priority.
      *     - Highest = 0
@@ -797,3 +852,33 @@ export class Task {
         return description.match(TaskRegularExpressions.hashTags)?.map((tag) => tag.trim()) ?? [];
     }
 }
+
+/**
+ * A task is treated as blocked if it depends on any existing task ids on tasks that are TODO or IN_PROGRESS.
+ *
+ * 'Done' tasks (with status DONE, CANCELLED or NON_TASK) are never blocked.
+ * @param thisTask
+ * @param allTasks
+ */
+export function isBlocked(thisTask: Task, allTasks: Task[]) {
+    if (thisTask.blockedBy.length === 0) {
+        return false;
+    }
+
+    if (thisTask.isDone) {
+        return false;
+    }
+
+    for (const depId of thisTask.blockedBy) {
+        const depTask = allTasks.find((task) => task.id === depId && !task.isDone);
+        if (!depTask) {
+            // There is no not-done task with this id.
+            continue;
+        }
+
+        // We found a not-done task that this depends on, meaning this one is blocked:
+        return true;
+    }
+
+    return false;
+}


Test patch:
diff --git a/tests/Task/Task.test.task_dependencies_blocking_and_blocked.approved.txt b/tests/Task/Task.test.task_dependencies_blocking_and_blocked.approved.txt
new file mode 100644
index 00000000..040f17c8
--- /dev/null
+++ b/tests/Task/Task.test.task_dependencies_blocking_and_blocked.approved.txt
@@ -0,0 +1,119 @@
+Visualise blocking methods on Task, for a collection of tasks
+
+
+
+- [ ] No dependency - TODO
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [x] No dependency - DONE
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 1 - TODO depends on TODO 🆔 scenario1
+    isBlocked():  ❌ false
+    isBlocking(): ✅ true
+
+- [ ] scenario 1 - TODO depends on TODO ⛔️ scenario1
+    isBlocked():  ✅ true
+    isBlocking(): ❌ false
+
+- [x] scenario 2 - TODO depends on DONE 🆔 scenario2
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 2 - TODO depends on DONE ⛔️ scenario2
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 3 - DONE depends on TODO 🆔 scenario3
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [x] scenario 3 - DONE depends on TODO ⛔️ scenario3
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [x] scenario 4 - DONE depends on DONE 🆔 scenario4
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [x] scenario 4 - DONE depends on DONE ⛔️ scenario4
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 5 - TODO depends on non-existing ID ⛔️ nosuchid
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 6 - TODO depends on self 🆔 self ⛔️ self
+    isBlocked():  ✅ true
+    isBlocking(): ✅ true
+
+- [x] scenario 7 - task with duplicated id - this is DONE                                  - 🆔 scenario7
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 7 - task with duplicated id - this is TODO - and is blocking                - 🆔 scenario7
+    isBlocked():  ❌ false
+    isBlocking(): ✅ true
+
+- [ ] scenario 7 - TODO depends on id that is duplicated - ensure all tasks are checked    - ⛔️ scenario7
+    isBlocked():  ✅ true
+    isBlocking(): ❌ false
+
+- [ ] scenario 8 - mutually dependant 🆔 scenario8a ⛔️ scenario8a
+    isBlocked():  ✅ true
+    isBlocking(): ✅ true
+
+- [ ] scenario 8 - mutually dependant 🆔 scenario8b ⛔️ scenario8a
+    isBlocked():  ✅ true
+    isBlocking(): ❌ false
+
+- [ ] scenario 9 - cyclic dependency 🆔 scenario9a ⛔️ scenario9c
+    isBlocked():  ✅ true
+    isBlocking(): ✅ true
+
+- [ ] scenario 9 - cyclic dependency 🆔 scenario9b ⛔️ scenario9a
+    isBlocked():  ✅ true
+    isBlocking(): ✅ true
+
+- [ ] scenario 9 - cyclic dependency 🆔 scenario9c ⛔️ scenario9b
+    isBlocked():  ✅ true
+    isBlocking(): ✅ true
+
+- [ ] scenario 10 - multiple dependencies TODO         - 🆔 scenario10a
+    isBlocked():  ❌ false
+    isBlocking(): ✅ true
+
+- [/] scenario 10 - multiple dependencies IN_PROGRESS  - 🆔 scenario10b
+    isBlocked():  ❌ false
+    isBlocking(): ✅ true
+
+- [x] scenario 10 - multiple dependencies DONE         - 🆔 scenario10c
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [-] scenario 10 - multiple dependencies CANCELLED    - 🆔 scenario10d
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [Q] scenario 10 - multiple dependencies NON_TASK     - 🆔 scenario10e
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 10 - multiple dependencies              - ⛔️ scenario10a,scenario10b,scenario10c,scenario10d,scenario10e
+    isBlocked():  ✅ true
+    isBlocking(): ❌ false
+
+- [ ] scenario 11 - indirect dependency - indirect blocking of scenario11c ignored - 🆔 scenario11a
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [x] scenario 11 - indirect dependency - DONE                                     - 🆔 scenario11b ⛔️ scenario11a
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
+
+- [ ] scenario 11 - indirect dependency - indirect blocking of scenario11a ignored - 🆔 scenario11c ⛔️ scenario11b
+    isBlocked():  ❌ false
+    isBlocking(): ❌ false
diff --git a/tests/Task/Task.test.ts b/tests/Task/Task.test.ts
index 7eab6c49..bce92601 100644
--- a/tests/Task/Task.test.ts
+++ b/tests/Task/Task.test.ts
@@ -2,6 +2,7 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
+import { verifyAll } from 'approvals/lib/Providers/Jest/JestApprovals';
 import { Status } from '../../src/Statuses/Status';
 import { Task } from '../../src/Task/Task';
 import { resetSettings, updateSettings } from '../../src/Config/Settings';
@@ -10,7 +11,7 @@ import type { StatusCollection } from '../../src/Statuses/StatusCollection';
 import { StatusRegistry } from '../../src/Statuses/StatusRegistry';
 import { TaskLocation } from '../../src/Task/TaskLocation';
 import { StatusConfiguration, StatusType } from '../../src/Statuses/StatusConfiguration';
-import { fromLine } from '../TestingTools/TestHelpers';
+import { fromLine, fromLines } from '../TestingTools/TestHelpers';
 import { TaskBuilder } from '../TestingTools/TaskBuilder';
 import { RecurrenceBuilder } from '../TestingTools/RecurrenceBuilder';
 import { Priority } from '../../src/Task/Priority';
@@ -550,6 +551,74 @@ describe('properties for scripting', () => {
     });
 });
 
+describe('task dependencies', () => {
+    beforeEach(() => {
+        const nonTaskStatus = new StatusConfiguration('Q', 'Question', 'A', true, StatusType.NON_TASK);
+        StatusRegistry.getInstance().add(nonTaskStatus);
+    });
+
+    afterEach(() => {
+        StatusRegistry.getInstance().resetToDefaultStatuses();
+    });
+
+    function toEmoji(boolean: boolean) {
+        return boolean ? '✅ true' : '❌ false';
+    }
+
+    it('blocking and blocked', () => {
+        const lines = [
+            '- [ ] No dependency - TODO',
+            '- [x] No dependency - DONE',
+            //
+            '- [ ] scenario 1 - TODO depends on TODO 🆔 scenario1',
+            '- [ ] scenario 1 - TODO depends on TODO ⛔️ scenario1',
+            //
+            '- [x] scenario 2 - TODO depends on DONE 🆔 scenario2',
+            '- [ ] scenario 2 - TODO depends on DONE ⛔️ scenario2',
+            //
+            '- [ ] scenario 3 - DONE depends on TODO 🆔 scenario3',
+            '- [x] scenario 3 - DONE depends on TODO ⛔️ scenario3',
+            //
+            '- [x] scenario 4 - DONE depends on DONE 🆔 scenario4',
+            '- [x] scenario 4 - DONE depends on DONE ⛔️ scenario4',
+            //
+            '- [ ] scenario 5 - TODO depends on non-existing ID ⛔️ nosuchid',
+            //
+            '- [ ] scenario 6 - TODO depends on self 🆔 self ⛔️ self',
+            //
+            '- [x] scenario 7 - task with duplicated id - this is DONE                                  - 🆔 scenario7',
+            '- [ ] scenario 7 - task with duplicated id - this is TODO - and is blocking                - 🆔 scenario7',
+            '- [ ] scenario 7 - TODO depends on id that is duplicated - ensure all tasks are checked    - ⛔️ scenario7',
+            //
+            '- [ ] scenario 8 - mutually dependant 🆔 scenario8a ⛔️ scenario8a',
+            '- [ ] scenario 8 - mutually dependant 🆔 scenario8b ⛔️ scenario8a',
+            //
+            '- [ ] scenario 9 - cyclic dependency 🆔 scenario9a ⛔️ scenario9c',
+            '- [ ] scenario 9 - cyclic dependency 🆔 scenario9b ⛔️ scenario9a',
+            '- [ ] scenario 9 - cyclic dependency 🆔 scenario9c ⛔️ scenario9b',
+            //
+            '- [ ] scenario 10 - multiple dependencies TODO         - 🆔 scenario10a',
+            '- [/] scenario 10 - multiple dependencies IN_PROGRESS  - 🆔 scenario10b',
+            '- [x] scenario 10 - multiple dependencies DONE         - 🆔 scenario10c',
+            '- [-] scenario 10 - multiple dependencies CANCELLED    - 🆔 scenario10d',
+            '- [Q] scenario 10 - multiple dependencies NON_TASK     - 🆔 scenario10e',
+            '- [ ] scenario 10 - multiple dependencies              - ⛔️ scenario10a,scenario10b,scenario10c,scenario10d,scenario10e',
+            //
+            '- [ ] scenario 11 - indirect dependency - indirect blocking of scenario11c ignored - 🆔 scenario11a',
+            '- [x] scenario 11 - indirect dependency - DONE                                     - 🆔 scenario11b ⛔️ scenario11a',
+            '- [ ] scenario 11 - indirect dependency - indirect blocking of scenario11a ignored - 🆔 scenario11c ⛔️ scenario11b',
+        ];
+        const tasks = fromLines({ lines });
+
+        verifyAll('Visualise blocking methods on Task, for a collection of tasks', tasks, (task) => {
+            return `
+${task.toFileLineString()}
+    isBlocked():  ${toEmoji(task.isBlocked(tasks))}
+    isBlocking(): ${toEmoji(task.isBlocking(tasks))}`;
+        });
+    });
+});
+
 describe('backlinks', () => {
     function shouldGiveLinkText(
         path: string,