Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2526 (2023-12-22 18:07:34)

refactor: Move new status calculation for recurring tasks to StatusRegistry
<!--- Provide a general summary of your changes in the Title above -->

# Description

Move the new code from the following PR from `Task` to `StatusRegistry`:

- #2516

Also: 

- added some comments to explain the new recurrence status logic. The comments were kindly written by @asokawotulo in:
https://github.com/obsidian-tasks-group/obsidian-tasks/pull/2516#discussion_r1434113802


Other changes made, in order to allow re-use of some existing test code:

- Move some existing verify functions to `tests/TestingTools/VerifyStatuses.ts`
    - Also added `verifyTaskList()` and `verifyTaskListInReverseOrder()`
- Extract functions and move some code for creating sample status sets to `tests/TestingTools/StatusExamples.ts`
- Move `constructStatuses()` to `tests/TestingTools/StatusesTestHelpers.ts`


## Motivation and Context

I will need to update the [When DONE is not followed by TODO or IN_PROGRESS](https://publish.obsidian.md/tasks/Getting+Started/Statuses/Recurring+Tasks+and+Custom+Statuses#When+DONE+is+not+followed+by+TODO+or+IN_PROGRESS) section in the docs, now that DONE will advance to TODO in that example.

This means that I will need to update the Mermaid diagrams in order to show how new statuses are calculated for recurring tasks - and that requires the code to be callable in `StatusRegistry.mermaidDiagram()`.

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## 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. -->

- By adding a new test
- By exploratory testing

## 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`.
- [ ] 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/StatusRegistry.ts b/src/StatusRegistry.ts
index 98faf93e..a7232b19 100644
--- a/src/StatusRegistry.ts
+++ b/src/StatusRegistry.ts
@@ -199,6 +199,32 @@ export class StatusRegistry {
         return Status.createUnknownStatus(status.nextStatusSymbol);
     }
 
+    /**
+     * Return the status to use for a recurring task that has just been completed.
+     * If the next status is not TODO, it advances through the next statuses until it either
+     * finds a {@link StatusType.TODO} status, or goes ahead and returns the next status after {@link newStatus}.
+     *
+     * @param newStatus - the new status of the task that has just been toggled, which
+     *                    is expected to be of type {@link StatusType.DONE}, but this is not checked.
+     */
+    public getNextRecurrenceStatusOrCreate(newStatus: Status) {
+        let nextStatus = this.getNextStatusOrCreate(newStatus);
+        if (nextStatus.type !== StatusType.TODO) {
+            let searchStatus = nextStatus;
+            // The goal here is to avoid an infinite loop. By limiting the search to the number of
+            // configured statuses, we ensure it doesn't continue indefinitely.
+            for (let i = 0; i < this.registeredStatuses.length - 1; i++) {
+                searchStatus = this.getNextStatusOrCreate(searchStatus);
+                if (searchStatus.type === StatusType.TODO) {
+                    nextStatus = searchStatus;
+                    break;
+                }
+            }
+            // If it fails to find any TODO status, it will use the next symbol after DONE.
+        }
+        return nextStatus;
+    }
+
     /**
      * Find any statuses in the given list that are not known to this registry.
      * This can be used to add all unknown status types to the settings,
diff --git a/src/Task.ts b/src/Task.ts
index 5563ecc9..b517d08b 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -429,17 +429,7 @@ export class Task {
                 createdDate = window.moment();
             }
             const statusRegistry = StatusRegistry.getInstance();
-            let nextStatus = statusRegistry.getNextStatusOrCreate(newStatus);
-            if (nextStatus.type !== StatusType.TODO) {
-                let searchStatus = nextStatus;
-                for (let i = 0; i < statusRegistry.registeredStatuses.length - 1; i++) {
-                    searchStatus = statusRegistry.getNextStatusOrCreate(searchStatus);
-                    if (searchStatus.type === StatusType.TODO) {
-                        nextStatus = searchStatus;
-                        break;
-                    }
-                }
-            }
+            const nextStatus = statusRegistry.getNextRecurrenceStatusOrCreate(newStatus);
             const nextTask = new Task({
                 ...this,
                 ...nextOccurrence,


Test patch:
diff --git a/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts b/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
index 45085af5..2d874fc4 100644
--- a/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
+++ b/tests/DocumentationSamples/DocsSamplesForStatuses.test.ts
@@ -1,173 +1,59 @@
-import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
-
-import { StatusField } from '../../src/Query/Filter/StatusField';
 import { Status } from '../../src/Status';
-import type { Task } from '../../src/Task';
 import { StatusConfiguration, StatusType } from '../../src/StatusConfiguration';
-import type { FilterOrErrorMessage } from '../../src/Query/Filter/FilterOrErrorMessage';
-import * as FilterParser from '../../src/Query/FilterParser';
-import { StatusNameField } from '../../src/Query/Filter/StatusNameField';
-import { StatusTypeField } from '../../src/Query/Filter/StatusTypeField';
 import type { StatusCollection, StatusCollectionEntry } from '../../src/StatusCollection';
 import * as Themes from '../../src/Config/Themes';
 import { StatusValidator } from '../../src/StatusValidator';
-import { TaskBuilder } from '../TestingTools/TaskBuilder';
-import { verifyMarkdownForDocs } from '../TestingTools/VerifyMarkdown';
-import { StatusRegistry } from '../../src/StatusRegistry';
-import { verifyWithFileExtension } from '../TestingTools/ApprovalTestHelpers';
-import { SearchInfo } from '../../src/Query/SearchInfo';
-import type { GrouperFunction } from '../../src/Query/Grouper';
-import { MarkdownTable } from '../../src/lib/MarkdownTable';
-import { getPrintableSymbol } from '../../src/StatusRegistryReport';
-
-function verifyStatusesAsMarkdownTable(statuses: Status[], showQueryInstructions: boolean) {
-    // Note: There is very similar code in tabulateStatusSettings() in StatusRegistryReport.ts.
-    //       Maybe try unifying the common code one day?
-    let statusName = 'Status Name';
-    let statusType = 'Status Type';
-    if (showQueryInstructions) {
-        statusName += '<br>`status.name includes...`<br>`sort by status.name`<br>`group by status.name`';
-        statusType += '<br>`status.type is...`<br>`sort by status.type`<br>`group by status.type`';
-    }
-    const table = new MarkdownTable([
-        'Status Symbol',
-        'Next Status Symbol',
-        statusName,
-        statusType,
-        'Needs Custom Styling',
-    ]);
-
-    for (const status of statuses) {
-        const statusCharacter = getPrintableSymbol(status.symbol);
-        const nextStatusCharacter = getPrintableSymbol(status.nextStatusSymbol);
-        const type = getPrintableSymbol(status.type);
-        const needsCustomStyling = status.symbol !== ' ' && status.symbol !== 'x' ? 'Yes' : 'No';
-        table.addRow([statusCharacter, nextStatusCharacter, status.name, type, needsCustomStyling]);
-    }
-    verifyMarkdownForDocs(table.markdown);
-}
-
-function verifyStatusesAsTasksList(statuses: Status[]) {
-    let markdown = '';
-    for (const status of statuses) {
-        const statusCharacter = getPrintableSymbol(status.symbol);
-        markdown += `- [${status.symbol}] #task ${statusCharacter} ${status.name}\n`;
-    }
-    verifyMarkdownForDocs(markdown);
-}
-
-function verifyStatusesAsTasksText(statuses: Status[]) {
-    let markdown = '';
-    for (const status of statuses) {
-        const statusCharacter = getPrintableSymbol(status.symbol);
-        markdown += `- [${status.symbol}] #task ${statusCharacter} ${status.name}\n`;
-    }
-    verify(markdown);
-}
-
-function constructStatuses(importedStatuses: StatusCollection) {
-    const statuses: Status[] = [];
-    importedStatuses.forEach((importedStatus) => {
-        statuses.push(Status.createFromImportedValue(importedStatus));
-    });
-    return statuses;
-}
-
-function verifyStatusesInMultipleFormats(statuses: Status[], showQueryInstructions: boolean) {
-    verifyStatusesAsMarkdownTable(statuses, showQueryInstructions);
-    verifyStatusesAsMermaidDiagram(statuses);
-}
-
-function verifyStatusesAsMermaidDiagramImpl(statuses: Status[], detailed: boolean, extensionWithoutDot: string) {
-    // Set the registry up to exactly match the supplied statuses
-    const registry = new StatusRegistry();
-    registry.set(statuses);
-
-    const markdown = registry.mermaidDiagram(detailed);
-    verifyWithFileExtension(markdown, extensionWithoutDot);
-}
-
-function verifyStatusesAsMermaidDiagram(statuses: Status[]) {
-    verifyStatusesAsMermaidDiagramImpl(statuses, false, 'mermaid.md');
-}
-
-function verifyStatusesAsDetailedMermaidDiagram(statuses: Status[]) {
-    verifyStatusesAsMermaidDiagramImpl(statuses, true, 'detailed.mermaid.md');
-}
+import * as VerifyStatuses from '../TestingTools/VerifyStatuses';
+import * as StatusExamples from '../TestingTools/StatusExamples';
+import { constructStatuses } from '../TestingTools/StatusesTestHelpers';
 
 describe('DefaultStatuses', () => {
     // These "test" write out a markdown representation of the default task statuses,
     // for embedding in the user docs.
     it('core-statuses', () => {
-        verifyStatusesInMultipleFormats([Status.makeTodo(), Status.makeDone()], true);
+        VerifyStatuses.verifyStatusesInMultipleFormats([Status.makeTodo(), Status.makeDone()], true);
     });
 
     it('custom-statuses', () => {
-        verifyStatusesInMultipleFormats([Status.makeInProgress(), Status.makeCancelled()], true);
+        VerifyStatuses.verifyStatusesInMultipleFormats([Status.makeInProgress(), Status.makeCancelled()], true);
     });
 
     it('important-cycle', () => {
-        const importantCycle: StatusCollection = [
-            ['!', 'Important', 'D', 'TODO'],
-            ['D', 'Doing - Important', 'X', 'IN_PROGRESS'],
-            ['X', 'Done - Important', '!', 'DONE'],
-        ];
-        verifyStatusesInMultipleFormats(constructStatuses(importantCycle), false);
+        const statuses = StatusExamples.importantCycle();
+        VerifyStatuses.verifyStatusesInMultipleFormats(constructStatuses(statuses), false);
     });
 
     it('todo-in_progress-done', () => {
-        const importantCycle: StatusCollection = [
-            [' ', 'Todo', '/', 'TODO'],
-            ['/', 'In Progress', 'x', 'IN_PROGRESS'],
-            ['x', 'Done', ' ', 'DONE'],
-        ];
-        verifyStatusesInMultipleFormats(constructStatuses(importantCycle), false);
-        verifyStatusesAsDetailedMermaidDiagram(constructStatuses(importantCycle));
+        const statuses = StatusExamples.todoToInProgressToDone();
+        VerifyStatuses.verifyStatusesInMultipleFormats(constructStatuses(statuses), false);
+        VerifyStatuses.verifyStatusesAsDetailedMermaidDiagram(constructStatuses(statuses));
     });
 
     it('pro-con-cycle', () => {
-        const importantCycle: StatusCollection = [
-            ['P', 'Pro', 'C', 'NON_TASK'],
-            ['C', 'Con', 'P', 'NON_TASK'],
-        ];
-        verifyStatusesInMultipleFormats(constructStatuses(importantCycle), false);
-        verifyStatusesAsDetailedMermaidDiagram(constructStatuses(importantCycle));
+        const statuses = StatusExamples.proCon();
+        VerifyStatuses.verifyStatusesInMultipleFormats(constructStatuses(statuses), false);
+        VerifyStatuses.verifyStatusesAsDetailedMermaidDiagram(constructStatuses(statuses));
     });
 
     it('toggle-does-nothing', () => {
-        const importantCycle: StatusCollection = [
-            ['b', 'Bookmark', 'b', 'NON_TASK'],
-            ['E', 'Example', 'E', 'NON_TASK'],
-            ['I', 'Information', 'I', 'NON_TASK'],
-            ['P', 'Paraphrase', 'P', 'NON_TASK'],
-            ['Q', 'Quote', 'Q', 'NON_TASK'],
-        ];
-        verifyStatusesInMultipleFormats(constructStatuses(importantCycle), false);
+        const statuses = StatusExamples.variousNonTaskStatuses();
+        VerifyStatuses.verifyStatusesInMultipleFormats(constructStatuses(statuses), false);
     });
 
     it('done-toggles-to-cancelled', () => {
         // See issue #2089.
         // DONE is followed by CANCELLED, which currently causes unexpected behaviour in recurrent tasks.
         // This uses the 4 default statuses, and just customises their order.
-        const statuses: StatusCollection = [
-            [' ', 'Todo', '/', 'TODO'],
-            ['x', 'Done', '-', 'DONE'],
-            ['/', 'In Progress', 'x', 'IN_PROGRESS'],
-            ['-', 'Cancelled', ' ', 'CANCELLED'],
-        ];
-        verifyStatusesAsDetailedMermaidDiagram(constructStatuses(statuses));
+        const statuses = StatusExamples.doneTogglesToCancelled();
+        VerifyStatuses.verifyStatusesAsDetailedMermaidDiagram(constructStatuses(statuses));
     });
 
     it('done-toggles-to-cancelled-with-unconventional-symbols', () => {
         // See issue #2304.
         // DONE is followed by CANCELLED, which currently causes unexpected behaviour in recurrent tasks.
         // This doesn't follow the standard convention of 'x' means DONE. It has 'x' means CANCELLED.
-        const statuses: StatusCollection = [
-            [' ', 'Todo', '*', 'TODO'],
-            ['*', 'Done', 'x', 'DONE'],
-            ['x', 'Cancelled', ' ', 'CANCELLED'],
-        ];
-        verifyStatusesAsDetailedMermaidDiagram(constructStatuses(statuses));
+        const statuses = StatusExamples.doneTogglesToCancelledWithUnconventionalSymbols();
+        VerifyStatuses.verifyStatusesAsDetailedMermaidDiagram(constructStatuses(statuses));
     });
 });
 
@@ -192,78 +78,19 @@ describe('Theme', () => {
         });
 
         it('Table', () => {
-            verifyStatusesInMultipleFormats(constructStatuses(statuses), true);
+            VerifyStatuses.verifyStatusesInMultipleFormats(constructStatuses(statuses), true);
         });
 
         it('Tasks', () => {
-            verifyStatusesAsTasksList(constructStatuses(statuses));
+            VerifyStatuses.verifyStatusesAsTasksList(constructStatuses(statuses));
         });
 
         it('Text', () => {
-            verifyStatusesAsTasksText(constructStatuses(statuses));
+            VerifyStatuses.verifyStatusesAsTasksText(constructStatuses(statuses));
         });
     });
 });
 
-function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
-    const columnNames: string[] = ['Operation and status.type'];
-    statuses.forEach((s) => {
-        const title = s.type;
-        columnNames.push(title);
-    });
-
-    const table = new MarkdownTable(columnNames);
-
-    const tasks: Task[] = [];
-    {
-        const cells: string[] = ['Example Task'];
-        statuses.forEach((s) => {
-            const task = new TaskBuilder().status(s).description('demo').build();
-            tasks.push(task);
-            cells.push('`' + task!.toFileLineString() + '`');
-        });
-        table.addRow(cells);
-    }
-
-    function filterAllStatuses(filter: FilterOrErrorMessage) {
-        const cells: string[] = [`Matches \`${filter!.instruction}\``];
-        const searchInfo = SearchInfo.fromAllTasks(tasks);
-        tasks.forEach((task) => {
-            const matchedText = filter!.filter?.filterFunction(task, searchInfo) ? 'YES' : 'no';
-            cells.push(matchedText);
-        });
-        table.addRow(cells);
-    }
-
-    filterAllStatuses(FilterParser.parseFilter('not done')!);
-    filterAllStatuses(FilterParser.parseFilter('done')!);
-    filterAllStatuses(FilterParser.parseFilter('status.type is TODO')!);
-    filterAllStatuses(FilterParser.parseFilter('status.type is IN_PROGRESS')!);
-    filterAllStatuses(FilterParser.parseFilter('status.type is DONE')!);
-    filterAllStatuses(FilterParser.parseFilter('status.type is CANCELLED')!);
-    filterAllStatuses(FilterParser.parseFilter('status.type is NON_TASK')!);
-    filterAllStatuses(FilterParser.parseFilter('status.name includes todo')!);
-    filterAllStatuses(FilterParser.parseFilter('status.name includes in progress')!);
-    filterAllStatuses(FilterParser.parseFilter('status.name includes done')!);
-    filterAllStatuses(FilterParser.parseFilter('status.name includes cancelled')!);
-
-    function showGroupNamesForAllTasks(groupName: string, grouperFunction: GrouperFunction) {
-        const cells: string[] = ['Name for `group by ' + groupName + '`'];
-        tasks.forEach((task) => {
-            const groupNamesForTask = grouperFunction(task, SearchInfo.fromAllTasks([task]));
-            const names = groupNamesForTask.join(',');
-            cells.push(names);
-        });
-        table.addRow(cells);
-    }
-
-    showGroupNamesForAllTasks('status', new StatusField().createNormalGrouper().grouper);
-    showGroupNamesForAllTasks('status.type', new StatusTypeField().createNormalGrouper().grouper);
-    showGroupNamesForAllTasks('status.name', new StatusNameField().createNormalGrouper().grouper);
-
-    verifyMarkdownForDocs(table.markdown);
-}
-
 describe('Status Transitions', () => {
     it('status-types', () => {
         const statuses = [
@@ -273,6 +100,6 @@ describe('Status Transitions', () => {
             Status.makeCancelled(),
             new Status(new StatusConfiguration('~', 'My custom status', ' ', false, StatusType.NON_TASK)),
         ];
-        verifyTransitionsAsMarkdownTable(statuses);
+        VerifyStatuses.verifyTransitionsAsMarkdownTable(statuses);
     });
 });
diff --git a/tests/StatusRegistry.test.ts b/tests/StatusRegistry.test.ts
index 1588c436..5a12029a 100644
--- a/tests/StatusRegistry.test.ts
+++ b/tests/StatusRegistry.test.ts
@@ -8,6 +8,8 @@ import { StatusConfiguration, StatusType } from '../src/StatusConfiguration';
 import { Task } from '../src/Task';
 import { TaskLocation } from '../src/TaskLocation';
 import * as TestHelpers from './TestHelpers';
+import * as StatusExamples from './TestingTools/StatusExamples';
+import { constructStatuses } from './TestingTools/StatusesTestHelpers';
 
 jest.mock('obsidian');
 window.moment = moment;
@@ -474,4 +476,26 @@ describe('StatusRegistry', () => {
             expect(toggled?.status.symbol).toEqual('D');
         });
     });
+
+    describe('toggling recurring', () => {
+        it('should make CANCELLED next task TODO', () => {
+            // See #2304:
+            // Completing a recurring task setting wrong status for new task [if the next custom status is not TODO]
+
+            // Arrange
+            const statusRegistry = new StatusRegistry();
+            const statuses = StatusExamples.doneTogglesToCancelled();
+            statusRegistry.set(constructStatuses(statuses));
+
+            const initialStatusForRecurringTask = statusRegistry.bySymbol('/');
+
+            // Act, Assert
+            const toggledStatus = statusRegistry.getNextStatusOrCreate(initialStatusForRecurringTask);
+            expect(toggledStatus).toEqual(statusRegistry.bySymbol('x'));
+
+            // Ensure that the next status skips through to TODO for a recurring task
+            const nextStatus = statusRegistry.getNextRecurrenceStatusOrCreate(toggledStatus);
+            expect(nextStatus).toEqual(statusRegistry.bySymbol(' '));
+        });
+    });
 });
diff --git a/tests/TestingTools/ApprovalTestHelpers.ts b/tests/TestingTools/ApprovalTestHelpers.ts
index c223f1ad..ed94dff0 100644
--- a/tests/TestingTools/ApprovalTestHelpers.ts
+++ b/tests/TestingTools/ApprovalTestHelpers.ts
@@ -4,6 +4,8 @@ import type { GlobalFilter } from '../../src/Config/GlobalFilter';
 import type { GlobalQuery } from '../../src/Config/GlobalQuery';
 import { Query } from '../../src/Query/Query';
 import { explainResults } from '../../src/lib/QueryRendererHelper';
+import type { Task } from '../../src/Task';
+import { verifyMarkdown } from './VerifyMarkdown';
 
 export function printIteration<T1>(func: <T1>(t1: T1) => any, params1: T1[]): string {
     const EMPTY_ENTRY = {};
@@ -95,3 +97,27 @@ export function verifyTaskBlockExplanation(
 
     verifyWithFileExtension(explanation, 'explanation.text', options);
 }
+
+/**
+ * Write tasks to a markdown file, to enable their contents to be visualised.
+ *
+ * They are written in the order they are given.
+ * @param tasks
+ * @see verifyTaskListInReverseOrder
+ */
+export function verifyTaskList(tasks: Task[]) {
+    verifyMarkdown(tasks.map((task) => task.toFileLineString()).join('\n'));
+}
+
+/**
+ * Write tasks to a markdown file, to enable their contents to be visualised.
+ *
+ * They are written in reverse order, which may be useful for easier understand of toggled tasks.
+ *
+ * @param tasks
+ * @see verifyTaskList
+ */
+export function verifyTaskListInReverseOrder(tasks: Task[]) {
+    const reverse = [...tasks].reverse();
+    verifyTaskList(reverse);
+}
diff --git a/tests/TestingTools/StatusExamples.ts b/tests/TestingTools/StatusExamples.ts
new file mode 100644
index 00000000..e88c889e
--- /dev/null
+++ b/tests/TestingTools/StatusExamples.ts
@@ -0,0 +1,57 @@
+import type { StatusCollection } from '../../src/StatusCollection';
+
+export function doneTogglesToCancelled() {
+    const statuses: StatusCollection = [
+        [' ', 'Todo', '/', 'TODO'],
+        ['x', 'Done', '-', 'DONE'],
+        ['/', 'In Progress', 'x', 'IN_PROGRESS'],
+        ['-', 'Cancelled', ' ', 'CANCELLED'],
+    ];
+    return statuses;
+}
+
+export function doneTogglesToCancelledWithUnconventionalSymbols() {
+    const statuses: StatusCollection = [
+        [' ', 'Todo', '*', 'TODO'],
+        ['*', 'Done', 'x', 'DONE'],
+        ['x', 'Cancelled', ' ', 'CANCELLED'],
+    ];
+    return statuses;
+}
+
+export function variousNonTaskStatuses() {
+    const importantCycle: StatusCollection = [
+        ['b', 'Bookmark', 'b', 'NON_TASK'],
+        ['E', 'Example', 'E', 'NON_TASK'],
+        ['I', 'Information', 'I', 'NON_TASK'],
+        ['P', 'Paraphrase', 'P', 'NON_TASK'],
+        ['Q', 'Quote', 'Q', 'NON_TASK'],
+    ];
+    return importantCycle;
+}
+
+export function importantCycle() {
+    const importantCycle: StatusCollection = [
+        ['!', 'Important', 'D', 'TODO'],
+        ['D', 'Doing - Important', 'X', 'IN_PROGRESS'],
+        ['X', 'Done - Important', '!', 'DONE'],
+    ];
+    return importantCycle;
+}
+
+export function todoToInProgressToDone() {
+    const importantCycle: StatusCollection = [
+        [' ', 'Todo', '/', 'TODO'],
+        ['/', 'In Progress', 'x', 'IN_PROGRESS'],
+        ['x', 'Done', ' ', 'DONE'],
+    ];
+    return importantCycle;
+}
+
+export function proCon() {
+    const importantCycle: StatusCollection = [
+        ['P', 'Pro', 'C', 'NON_TASK'],
+        ['C', 'Con', 'P', 'NON_TASK'],
+    ];
+    return importantCycle;
+}
diff --git a/tests/TestingTools/StatusesTestHelpers.ts b/tests/TestingTools/StatusesTestHelpers.ts
index f7691e42..88892bcc 100644
--- a/tests/TestingTools/StatusesTestHelpers.ts
+++ b/tests/TestingTools/StatusesTestHelpers.ts
@@ -29,3 +29,11 @@ export function createStatuses(
     StatusSettings.applyToStatusRegistry(statusSettings, statusRegistry);
     return { statusSettings, statusRegistry };
 }
+
+export function constructStatuses(importedStatuses: StatusCollection) {
+    const statuses: Status[] = [];
+    importedStatuses.forEach((importedStatus) => {
+        statuses.push(Status.createFromImportedValue(importedStatus));
+    });
+    return statuses;
+}
diff --git a/tests/TestingTools/VerifyStatuses.ts b/tests/TestingTools/VerifyStatuses.ts
new file mode 100644
index 00000000..c8f23431
--- /dev/null
+++ b/tests/TestingTools/VerifyStatuses.ts
@@ -0,0 +1,142 @@
+import { verify } from 'approvals/lib/Providers/Jest/JestApprovals';
+import { getPrintableSymbol } from '../../src/StatusRegistryReport';
+import type { Status } from '../../src/Status';
+import { MarkdownTable } from '../../src/lib/MarkdownTable';
+import type { Task } from '../../src/Task';
+import type { FilterOrErrorMessage } from '../../src/Query/Filter/FilterOrErrorMessage';
+import { SearchInfo } from '../../src/Query/SearchInfo';
+import * as FilterParser from '../../src/Query/FilterParser';
+import type { GrouperFunction } from '../../src/Query/Grouper';
+import { StatusField } from '../../src/Query/Filter/StatusField';
+import { StatusTypeField } from '../../src/Query/Filter/StatusTypeField';
+import { StatusNameField } from '../../src/Query/Filter/StatusNameField';
+import { StatusRegistry } from '../../src/StatusRegistry';
+import { TaskBuilder } from './TaskBuilder';
+import { verifyMarkdownForDocs } from './VerifyMarkdown';
+import { verifyWithFileExtension } from './ApprovalTestHelpers';
+
+function verifyStatusesAsMarkdownTable(statuses: Status[], showQueryInstructions: boolean) {
+    // Note: There is very similar code in tabulateStatusSettings() in StatusRegistryReport.ts.
+    //       Maybe try unifying the common code one day?
+    let statusName = 'Status Name';
+    let statusType = 'Status Type';
+    if (showQueryInstructions) {
+        statusName += '<br>`status.name includes...`<br>`sort by status.name`<br>`group by status.name`';
+        statusType += '<br>`status.type is...`<br>`sort by status.type`<br>`group by status.type`';
+    }
+    const table = new MarkdownTable([
+        'Status Symbol',
+        'Next Status Symbol',
+        statusName,
+        statusType,
+        'Needs Custom Styling',
+    ]);
+
+    for (const status of statuses) {
+        const statusCharacter = getPrintableSymbol(status.symbol);
+        const nextStatusCharacter = getPrintableSymbol(status.nextStatusSymbol);
+        const type = getPrintableSymbol(status.type);
+        const needsCustomStyling = status.symbol !== ' ' && status.symbol !== 'x' ? 'Yes' : 'No';
+        table.addRow([statusCharacter, nextStatusCharacter, status.name, type, needsCustomStyling]);
+    }
+    verifyMarkdownForDocs(table.markdown);
+}
+
+export function verifyStatusesAsTasksList(statuses: Status[]) {
+    let markdown = '';
+    for (const status of statuses) {
+        const statusCharacter = getPrintableSymbol(status.symbol);
+        markdown += `- [${status.symbol}] #task ${statusCharacter} ${status.name}\n`;
+    }
+    verifyMarkdownForDocs(markdown);
+}
+
+export function verifyStatusesAsTasksText(statuses: Status[]) {
+    let markdown = '';
+    for (const status of statuses) {
+        const statusCharacter = getPrintableSymbol(status.symbol);
+        markdown += `- [${status.symbol}] #task ${statusCharacter} ${status.name}\n`;
+    }
+    verify(markdown);
+}
+
+export function verifyStatusesInMultipleFormats(statuses: Status[], showQueryInstructions: boolean) {
+    verifyStatusesAsMarkdownTable(statuses, showQueryInstructions);
+    verifyStatusesAsMermaidDiagram(statuses);
+}
+
+function verifyStatusesAsMermaidDiagramImpl(statuses: Status[], detailed: boolean, extensionWithoutDot: string) {
+    // Set the registry up to exactly match the supplied statuses
+    const registry = new StatusRegistry();
+    registry.set(statuses);
+
+    const markdown = registry.mermaidDiagram(detailed);
+    verifyWithFileExtension(markdown, extensionWithoutDot);
+}
+
+function verifyStatusesAsMermaidDiagram(statuses: Status[]) {
+    verifyStatusesAsMermaidDiagramImpl(statuses, false, 'mermaid.md');
+}
+
+export function verifyStatusesAsDetailedMermaidDiagram(statuses: Status[]) {
+    verifyStatusesAsMermaidDiagramImpl(statuses, true, 'detailed.mermaid.md');
+}
+
+export function verifyTransitionsAsMarkdownTable(statuses: Status[]) {
+    const columnNames: string[] = ['Operation and status.type'];
+    statuses.forEach((s) => {
+        const title = s.type;
+        columnNames.push(title);
+    });
+
+    const table = new MarkdownTable(columnNames);
+
+    const tasks: Task[] = [];
+    {
+        const cells: string[] = ['Example Task'];
+        statuses.forEach((s) => {
+            const task = new TaskBuilder().status(s).description('demo').build();
+            tasks.push(task);
+            cells.push('`' + task!.toFileLineString() + '`');
+        });
+        table.addRow(cells);
+    }
+
+    function filterAllStatuses(filter: FilterOrErrorMessage) {
+        const cells: string[] = [`Matches \`${filter!.instruction}\``];
+        const searchInfo = SearchInfo.fromAllTasks(tasks);
+        tasks.forEach((task) => {
+            const matchedText = filter!.filter?.filterFunction(task, searchInfo) ? 'YES' : 'no';
+            cells.push(matchedText);
+        });
+        table.addRow(cells);
+    }
+
+    filterAllStatuses(FilterParser.parseFilter('not done')!);
+    filterAllStatuses(FilterParser.parseFilter('done')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type is TODO')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type is IN_PROGRESS')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type is DONE')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type is CANCELLED')!);
+    filterAllStatuses(FilterParser.parseFilter('status.type is NON_TASK')!);
+    filterAllStatuses(FilterParser.parseFilter('status.name includes todo')!);
+    filterAllStatuses(FilterParser.parseFilter('status.name includes in progress')!);
+    filterAllStatuses(FilterParser.parseFilter('status.name includes done')!);
+    filterAllStatuses(FilterParser.parseFilter('status.name includes cancelled')!);
+
+    function showGroupNamesForAllTasks(groupName: string, grouperFunction: GrouperFunction) {
+        const cells: string[] = ['Name for `group by ' + groupName + '`'];
+        tasks.forEach((task) => {
+            const groupNamesForTask = grouperFunction(task, SearchInfo.fromAllTasks([task]));
+            const names = groupNamesForTask.join(',');
+            cells.push(names);
+        });
+        table.addRow(cells);
+    }
+
+    showGroupNamesForAllTasks('status', new StatusField().createNormalGrouper().grouper);
+    showGroupNamesForAllTasks('status.type', new StatusTypeField().createNormalGrouper().grouper);
+    showGroupNamesForAllTasks('status.name', new StatusNameField().createNormalGrouper().grouper);
+
+    verifyMarkdownForDocs(table.markdown);
+}